mirror of
https://github.com/aljazceru/turso.git
synced 2025-12-17 08:34:19 +01:00
Merge 'Trigger support' from Jussi Saurio
## Trigger Support This PR adds support for triggers: - `CREATE TRIGGER` - `DROP TRIGGER` Supported - `BEFORE/AFTER INSERT` - `BEFORE/AFTER DELETE` - `BEFORE/AFTER UPDATE [OF <col1,col2,col3>]` Not supported: - `INSTEAD OF` - `TEMPORARY` ### Implementation details - Triggers are executed within a new `Insn::Program` instruction. The spec of the insn differs a bit from SQlite: we store a `Statement` inside that instruction that we can `reset()` for every invocation. - Like Sqlite, trigger programs take `NEW` and `OLD` rows as program parameters. Whenever there are triggers that would fire as the result of a DML statement: - `DELETE` writes the rows being deleted into a `RowSet` first. - `UPDATE` and `INSERT` write the rows being updated into an ephemeral table first. ### Other shit Also added `EXPLAIN` support - the bytecode plans for trigger subprograms are appended after the main program. ### AI disclosure Used Cursor quite a bit for generating boilerplate code for this - you can blame all the bad code on the AI of course 🤡 ### Follow-ups: 1. ALTER TABLE ops need to rewrite the sql in the CREATE TRIGGER statement e.g. if a column is renamed. Columns cannot be dropped if referenced in triggers. 2. Fix weird rowid -1 fallback: https://github.com/tursodatabase/turso/pull/3979#issuecomment-3547999449 Closes #3979
This commit is contained in:
77
core/lib.rs
77
core/lib.rs
@@ -41,6 +41,7 @@ pub mod numeric;
|
|||||||
mod numeric;
|
mod numeric;
|
||||||
|
|
||||||
use crate::index_method::IndexMethod;
|
use crate::index_method::IndexMethod;
|
||||||
|
use crate::schema::Trigger;
|
||||||
use crate::storage::checksum::CHECKSUM_REQUIRED_RESERVED_BYTES;
|
use crate::storage::checksum::CHECKSUM_REQUIRED_RESERVED_BYTES;
|
||||||
use crate::storage::encryption::AtomicCipherMode;
|
use crate::storage::encryption::AtomicCipherMode;
|
||||||
use crate::storage::pager::{AutoVacuumMode, HeaderRef};
|
use crate::storage::pager::{AutoVacuumMode, HeaderRef};
|
||||||
@@ -642,6 +643,8 @@ impl Database {
|
|||||||
view_transaction_states: AllViewsTxState::new(),
|
view_transaction_states: AllViewsTxState::new(),
|
||||||
metrics: RwLock::new(ConnectionMetrics::new()),
|
metrics: RwLock::new(ConnectionMetrics::new()),
|
||||||
nestedness: AtomicI32::new(0),
|
nestedness: AtomicI32::new(0),
|
||||||
|
compiling_triggers: RwLock::new(Vec::new()),
|
||||||
|
executing_triggers: RwLock::new(Vec::new()),
|
||||||
encryption_key: RwLock::new(None),
|
encryption_key: RwLock::new(None),
|
||||||
encryption_cipher_mode: AtomicCipherMode::new(CipherMode::None),
|
encryption_cipher_mode: AtomicCipherMode::new(CipherMode::None),
|
||||||
sync_mode: AtomicSyncMode::new(SyncMode::Full),
|
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]-> ...)
|
/// 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
|
/// and we need to track current nestedness depth in order to properly understand when we will reach the root back again
|
||||||
nestedness: AtomicI32,
|
nestedness: AtomicI32,
|
||||||
|
/// Stack of currently compiling triggers to prevent recursive trigger subprogram compilation
|
||||||
|
compiling_triggers: RwLock<Vec<Arc<Trigger>>>,
|
||||||
|
/// 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<Vec<Arc<Trigger>>>,
|
||||||
encryption_key: RwLock<Option<EncryptionKey>>,
|
encryption_key: RwLock<Option<EncryptionKey>>,
|
||||||
encryption_cipher_mode: AtomicCipherMode,
|
encryption_cipher_mode: AtomicCipherMode,
|
||||||
sync_mode: AtomicSyncMode,
|
sync_mode: AtomicSyncMode,
|
||||||
@@ -1212,6 +1220,52 @@ impl Connection {
|
|||||||
pub fn end_nested(&self) {
|
pub fn end_nested(&self) {
|
||||||
self.nestedness.fetch_add(-1, Ordering::SeqCst);
|
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<Trigger>) -> 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<Trigger>) {
|
||||||
|
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<Trigger>) -> 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<Trigger>) {
|
||||||
|
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<Connection>, sql: impl AsRef<str>) -> Result<Statement> {
|
pub fn prepare(self: &Arc<Connection>, sql: impl AsRef<str>) -> Result<Statement> {
|
||||||
if self.is_mvcc_bootstrap_connection() {
|
if self.is_mvcc_bootstrap_connection() {
|
||||||
// Never use MV store for bootstrapping - we read state directly from sqlite_schema in the DB file.
|
// 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()
|
self.db.mvcc_enabled()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn mv_store(&self) -> Option<&Arc<MvStore>> {
|
||||||
|
self.db.mv_store.as_ref()
|
||||||
|
}
|
||||||
|
|
||||||
/// Query the current value(s) of `pragma_name` associated to
|
/// Query the current value(s) of `pragma_name` associated to
|
||||||
/// `pragma_value`.
|
/// `pragma_value`.
|
||||||
///
|
///
|
||||||
@@ -2536,6 +2594,12 @@ pub struct Statement {
|
|||||||
busy_timeout: Option<BusyTimeout>,
|
busy_timeout: Option<BusyTimeout>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
impl Drop for Statement {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
self.reset();
|
self.reset();
|
||||||
@@ -2567,6 +2631,11 @@ impl Statement {
|
|||||||
busy_timeout: None,
|
busy_timeout: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn get_trigger(&self) -> Option<Arc<Trigger>> {
|
||||||
|
self.program.trigger.clone()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn get_query_mode(&self) -> QueryMode {
|
pub fn get_query_mode(&self) -> QueryMode {
|
||||||
self.query_mode
|
self.query_mode
|
||||||
}
|
}
|
||||||
@@ -2881,12 +2950,8 @@ impl Statement {
|
|||||||
|
|
||||||
fn reset_internal(&mut self, max_registers: Option<usize>, max_cursors: Option<usize>) {
|
fn reset_internal(&mut self, max_registers: Option<usize>, max_cursors: Option<usize>) {
|
||||||
// as abort uses auto_txn_cleanup value - it needs to be called before state.reset
|
// as abort uses auto_txn_cleanup value - it needs to be called before state.reset
|
||||||
self.program.abort(
|
self.program
|
||||||
self.mv_store.as_ref(),
|
.abort(self.mv_store.as_ref(), &self.pager, None, &mut self.state);
|
||||||
&self.pager,
|
|
||||||
None,
|
|
||||||
&mut self.state.auto_txn_cleanup,
|
|
||||||
);
|
|
||||||
self.state.reset(max_registers, max_cursors);
|
self.state.reset(max_registers, max_cursors);
|
||||||
self.busy = false;
|
self.busy = false;
|
||||||
self.busy_timeout = None;
|
self.busy_timeout = None;
|
||||||
|
|||||||
162
core/schema.rs
162
core/schema.rs
@@ -74,6 +74,47 @@ impl Clone for View {
|
|||||||
/// Type alias for regular views collection
|
/// Type alias for regular views collection
|
||||||
pub type ViewsMap = HashMap<String, Arc<View>>;
|
pub type ViewsMap = HashMap<String, Arc<View>>;
|
||||||
|
|
||||||
|
/// 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<turso_parser::ast::Expr>,
|
||||||
|
pub commands: Vec<turso_parser::ast::TriggerCmd>,
|
||||||
|
pub temporary: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Trigger {
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn new(
|
||||||
|
name: String,
|
||||||
|
sql: String,
|
||||||
|
table_name: String,
|
||||||
|
time: Option<turso_parser::ast::TriggerTime>,
|
||||||
|
event: turso_parser::ast::TriggerEvent,
|
||||||
|
for_each_row: bool,
|
||||||
|
when_clause: Option<turso_parser::ast::Expr>,
|
||||||
|
commands: Vec<turso_parser::ast::TriggerCmd>,
|
||||||
|
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::storage::btree::{BTreeCursor, CursorTrait};
|
||||||
use crate::translate::collate::CollationSeq;
|
use crate::translate::collate::CollationSeq;
|
||||||
use crate::translate::plan::{SelectPlan, TableReferences};
|
use crate::translate::plan::{SelectPlan, TableReferences};
|
||||||
@@ -130,6 +171,9 @@ pub struct Schema {
|
|||||||
|
|
||||||
pub views: ViewsMap,
|
pub views: ViewsMap,
|
||||||
|
|
||||||
|
/// table_name to list of triggers
|
||||||
|
pub triggers: HashMap<String, VecDeque<Arc<Trigger>>>,
|
||||||
|
|
||||||
/// table_name to list of indexes for the table
|
/// table_name to list of indexes for the table
|
||||||
pub indexes: HashMap<String, VecDeque<Arc<Index>>>,
|
pub indexes: HashMap<String, VecDeque<Arc<Index>>>,
|
||||||
pub has_indexes: std::collections::HashSet<String>,
|
pub has_indexes: std::collections::HashSet<String>,
|
||||||
@@ -163,6 +207,7 @@ impl Schema {
|
|||||||
let materialized_view_sql = HashMap::new();
|
let materialized_view_sql = HashMap::new();
|
||||||
let incremental_views = HashMap::new();
|
let incremental_views = HashMap::new();
|
||||||
let views: ViewsMap = HashMap::new();
|
let views: ViewsMap = HashMap::new();
|
||||||
|
let triggers = HashMap::new();
|
||||||
let table_to_materialized_views: HashMap<String, Vec<String>> = HashMap::new();
|
let table_to_materialized_views: HashMap<String, Vec<String>> = HashMap::new();
|
||||||
let incompatible_views = HashSet::new();
|
let incompatible_views = HashSet::new();
|
||||||
Self {
|
Self {
|
||||||
@@ -171,6 +216,7 @@ impl Schema {
|
|||||||
materialized_view_sql,
|
materialized_view_sql,
|
||||||
incremental_views,
|
incremental_views,
|
||||||
views,
|
views,
|
||||||
|
triggers,
|
||||||
indexes,
|
indexes,
|
||||||
has_indexes,
|
has_indexes,
|
||||||
indexes_enabled,
|
indexes_enabled,
|
||||||
@@ -310,6 +356,72 @@ impl Schema {
|
|||||||
self.views.get(&name).cloned()
|
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<Arc<Trigger>> {
|
||||||
|
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<Item = &Arc<Trigger>> + 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<Arc<Trigger>> {
|
||||||
|
let name = normalize_ident(name);
|
||||||
|
self.triggers
|
||||||
|
.values()
|
||||||
|
.flatten()
|
||||||
|
.find(|t| t.name == name)
|
||||||
|
.cloned()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn add_btree_table(&mut self, table: Arc<BTreeTable>) -> Result<()> {
|
pub fn add_btree_table(&mut self, table: Arc<BTreeTable>) -> Result<()> {
|
||||||
self.check_object_name_conflict(&table.name)?;
|
self.check_object_name_conflict(&table.name)?;
|
||||||
let name = normalize_ident(&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()
|
.iter()
|
||||||
.map(|(name, view)| (name.clone(), Arc::new((**view).clone())))
|
.map(|(name, view)| (name.clone(), Arc::new((**view).clone())))
|
||||||
.collect();
|
.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();
|
let incompatible_views = self.incompatible_views.clone();
|
||||||
Self {
|
Self {
|
||||||
tables,
|
tables,
|
||||||
@@ -1205,6 +1366,7 @@ impl Clone for Schema {
|
|||||||
materialized_view_sql,
|
materialized_view_sql,
|
||||||
incremental_views,
|
incremental_views,
|
||||||
views,
|
views,
|
||||||
|
triggers,
|
||||||
indexes,
|
indexes,
|
||||||
has_indexes: self.has_indexes.clone(),
|
has_indexes: self.has_indexes.clone(),
|
||||||
indexes_enabled: self.indexes_enabled,
|
indexes_enabled: self.indexes_enabled,
|
||||||
|
|||||||
@@ -817,14 +817,14 @@ impl Pager {
|
|||||||
|
|
||||||
/// Rollback to the newest savepoint. This basically just means reading the subjournal from the start offset
|
/// 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.
|
/// 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<bool> {
|
||||||
let subjournal = self.subjournal.read();
|
let subjournal = self.subjournal.read();
|
||||||
let Some(subjournal) = subjournal.as_ref() else {
|
let Some(subjournal) = subjournal.as_ref() else {
|
||||||
return Ok(());
|
return Ok(false);
|
||||||
};
|
};
|
||||||
let mut savepoints = self.savepoints.write();
|
let mut savepoints = self.savepoints.write();
|
||||||
let Some(savepoint) = savepoints.pop() else {
|
let Some(savepoint) = savepoints.pop() else {
|
||||||
return Ok(());
|
return Ok(false);
|
||||||
};
|
};
|
||||||
let journal_start_offset = savepoint.start_offset.load(Ordering::SeqCst);
|
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)?;
|
self.page_cache.write().truncate(db_size as usize)?;
|
||||||
|
|
||||||
Ok(())
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "test_helper")]
|
#[cfg(feature = "test_helper")]
|
||||||
|
|||||||
@@ -2,13 +2,16 @@ use crate::schema::{Schema, Table};
|
|||||||
use crate::translate::emitter::{emit_program, Resolver};
|
use crate::translate::emitter::{emit_program, Resolver};
|
||||||
use crate::translate::expr::process_returning_clause;
|
use crate::translate::expr::process_returning_clause;
|
||||||
use crate::translate::optimizer::optimize_plan;
|
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::planner::{parse_limit, parse_where};
|
||||||
|
use crate::translate::trigger_exec::has_relevant_triggers_type_only;
|
||||||
use crate::util::normalize_ident;
|
use crate::util::normalize_ident;
|
||||||
use crate::vdbe::builder::{ProgramBuilder, ProgramBuilderOpts};
|
use crate::vdbe::builder::{ProgramBuilder, ProgramBuilderOpts};
|
||||||
use crate::Result;
|
use crate::Result;
|
||||||
use std::sync::Arc;
|
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};
|
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() {
|
let table = if let Some(table) = table.virtual_table() {
|
||||||
Table::Virtual(table.clone())
|
Table::Virtual(table.clone())
|
||||||
} else if let Some(table) = table.btree() {
|
} else if let Some(table) = table.btree() {
|
||||||
@@ -130,18 +135,84 @@ pub fn prepare_delete_plan(
|
|||||||
let (resolved_limit, resolved_offset) =
|
let (resolved_limit, resolved_offset) =
|
||||||
limit.map_or(Ok((None, None)), |l| parse_limit(l, connection))?;
|
limit.map_or(Ok((None, None)), |l| parse_limit(l, connection))?;
|
||||||
|
|
||||||
let plan = DeletePlan {
|
// Check if there are DELETE triggers. If so, we need to materialize the write set into a RowSet first.
|
||||||
table_references,
|
// This is done in SQLite for all DELETE triggers on the affected table even if the trigger would not have an impact
|
||||||
result_columns,
|
// on the target table -- presumably due to lack of static analysis capabilities to determine whether it's safe
|
||||||
where_clause: where_predicates,
|
// to skip the rowset materialization.
|
||||||
order_by: vec![],
|
let has_delete_triggers = btree_table_for_triggers
|
||||||
limit: resolved_limit,
|
.as_ref()
|
||||||
offset: resolved_offset,
|
.map(|bt| has_relevant_triggers_type_only(schema, TriggerEvent::Delete, None, bt))
|
||||||
contains_constant_false_condition: false,
|
.unwrap_or(false);
|
||||||
indexes,
|
|
||||||
};
|
|
||||||
|
|
||||||
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 {
|
fn estimate_num_instructions(plan: &DeletePlan) -> usize {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,8 @@
|
|||||||
use std::num::NonZeroUsize;
|
use std::num::NonZeroUsize;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use turso_parser::ast::{
|
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::{
|
use crate::error::{
|
||||||
@@ -24,6 +25,7 @@ use crate::translate::plan::{
|
|||||||
ColumnUsedMask, JoinedTable, Operation, ResultSetColumn, TableReferences,
|
ColumnUsedMask, JoinedTable, Operation, ResultSetColumn, TableReferences,
|
||||||
};
|
};
|
||||||
use crate::translate::planner::ROWID_STRS;
|
use crate::translate::planner::ROWID_STRS;
|
||||||
|
use crate::translate::trigger_exec::{fire_trigger, get_relevant_triggers_type_and_time};
|
||||||
use crate::translate::upsert::{
|
use crate::translate::upsert::{
|
||||||
collect_set_clauses_for_upsert, emit_upsert, resolve_upsert_target, ResolvedUpsertTarget,
|
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::expr::{translate_expr, translate_expr_no_constant_opt, NoConstantOptReason};
|
||||||
use super::plan::QueryDestination;
|
use super::plan::QueryDestination;
|
||||||
use super::select::translate_select;
|
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
|
/// Validate anything with this insert statement that should throw an early parse error
|
||||||
fn validate(table_name: &str, resolver: &Resolver, table: &Table) -> Result<()> {
|
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)?;
|
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<usize> = 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 {
|
if has_user_provided_rowid {
|
||||||
let must_be_int_label = program.allocate_label();
|
let must_be_int_label = program.allocate_label();
|
||||||
|
|
||||||
@@ -427,6 +464,42 @@ pub fn translate_insert(
|
|||||||
table_name: table_name.to_string(),
|
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<usize> = 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 {
|
if has_fks {
|
||||||
// After the row is actually present, repair deferred counters for children referencing this NEW parent key.
|
// 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
|
// For REPLACE: delete increments counters above; the insert path should try to repay
|
||||||
@@ -1069,6 +1142,7 @@ fn bind_insert(
|
|||||||
}
|
}
|
||||||
match on_conflict {
|
match on_conflict {
|
||||||
ResolveType::Ignore => {
|
ResolveType::Ignore => {
|
||||||
|
program.set_resolve_type(ResolveType::Ignore);
|
||||||
upsert.replace(Box::new(ast::Upsert {
|
upsert.replace(Box::new(ast::Upsert {
|
||||||
do_clause: UpsertDo::Nothing,
|
do_clause: UpsertDo::Nothing,
|
||||||
index: None,
|
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 {
|
let (num_values, cursor_id) = match body {
|
||||||
InsertBody::Select(select, _) => {
|
InsertBody::Select(select, _) => {
|
||||||
// Simple common case of INSERT INTO <table> VALUES (...) without compounds.
|
// Simple common case of INSERT INTO <table> VALUES (...) without compounds.
|
||||||
@@ -1221,7 +1303,7 @@ fn init_source_emission<'a>(
|
|||||||
** of the tables being read by the SELECT statement. Also use a
|
** of the tables being read by the SELECT statement. Also use a
|
||||||
** temp table in the case of row triggers.
|
** 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 =
|
let temp_cursor_id =
|
||||||
program.alloc_cursor_id(CursorType::BTreeTable(ctx.table.clone()));
|
program.alloc_cursor_id(CursorType::BTreeTable(ctx.table.clone()));
|
||||||
ctx.temp_table_ctx = Some(TempTableCtx {
|
ctx.temp_table_ctx = Some(TempTableCtx {
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ pub(crate) mod schema;
|
|||||||
pub(crate) mod select;
|
pub(crate) mod select;
|
||||||
pub(crate) mod subquery;
|
pub(crate) mod subquery;
|
||||||
pub(crate) mod transaction;
|
pub(crate) mod transaction;
|
||||||
|
pub(crate) mod trigger;
|
||||||
|
pub(crate) mod trigger_exec;
|
||||||
pub(crate) mod update;
|
pub(crate) mod update;
|
||||||
pub(crate) mod upsert;
|
pub(crate) mod upsert;
|
||||||
mod values;
|
mod values;
|
||||||
@@ -169,7 +171,40 @@ pub fn translate_inner(
|
|||||||
program,
|
program,
|
||||||
connection,
|
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 {
|
ast::Stmt::CreateView {
|
||||||
view_name,
|
view_name,
|
||||||
select,
|
select,
|
||||||
@@ -232,7 +267,15 @@ pub fn translate_inner(
|
|||||||
if_exists,
|
if_exists,
|
||||||
tbl_name,
|
tbl_name,
|
||||||
} => translate_drop_table(tbl_name, resolver, if_exists, program, connection)?,
|
} => 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 {
|
ast::Stmt::DropView {
|
||||||
if_exists,
|
if_exists,
|
||||||
view_name,
|
view_name,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use std::{
|
use std::{
|
||||||
cell::RefCell,
|
cell::RefCell,
|
||||||
cmp::Ordering,
|
cmp::Ordering,
|
||||||
collections::{HashMap, VecDeque},
|
collections::{HashMap, HashSet, VecDeque},
|
||||||
sync::Arc,
|
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 lift_common_subexpressions::lift_common_subexpressions_from_binary_or_terms;
|
||||||
use order::{compute_order_target, plan_satisfies_order_target, EliminatesSortBy};
|
use order::{compute_order_target, plan_satisfies_order_target, EliminatesSortBy};
|
||||||
use turso_ext::{ConstraintInfo, ConstraintUsage};
|
use turso_ext::{ConstraintInfo, ConstraintUsage};
|
||||||
use turso_parser::ast::{self, Expr, SortOrder};
|
use turso_parser::ast::{self, Expr, SortOrder, TriggerEvent};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
schema::{BTreeTable, Index, IndexColumn, Schema, Table, ROWID_SENTINEL},
|
schema::{BTreeTable, Index, IndexColumn, Schema, Table, ROWID_SENTINEL},
|
||||||
@@ -27,6 +27,7 @@ use crate::{
|
|||||||
ColumnUsedMask, IndexMethodQuery, NonFromClauseSubquery, OuterQueryReference,
|
ColumnUsedMask, IndexMethodQuery, NonFromClauseSubquery, OuterQueryReference,
|
||||||
QueryDestination, ResultSetColumn, Scan, SeekKeyComponent,
|
QueryDestination, ResultSetColumn, Scan, SeekKeyComponent,
|
||||||
},
|
},
|
||||||
|
trigger_exec::has_relevant_triggers_type_only,
|
||||||
},
|
},
|
||||||
types::SeekOp,
|
types::SeekOp,
|
||||||
util::{
|
util::{
|
||||||
@@ -118,6 +119,10 @@ fn optimize_delete_plan(plan: &mut DeletePlan, schema: &Schema) -> Result<()> {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(rowset_plan) = plan.rowset_plan.as_mut() {
|
||||||
|
optimize_select_plan(rowset_plan, schema)?;
|
||||||
|
}
|
||||||
|
|
||||||
let _ = optimize_table_access(
|
let _ = optimize_table_access(
|
||||||
schema,
|
schema,
|
||||||
&mut plan.result_columns,
|
&mut plan.result_columns,
|
||||||
@@ -161,14 +166,31 @@ fn optimize_update_plan(
|
|||||||
|
|
||||||
let table_ref = &mut plan.table_references.joined_tables_mut()[0];
|
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
|
// An ephemeral table is required if:
|
||||||
// btree used to iterate over the table.
|
// 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 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.
|
// 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 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;
|
break 'requires false;
|
||||||
};
|
};
|
||||||
|
let btree_table = btree_table_arc.as_ref();
|
||||||
|
|
||||||
|
// Check if there are UPDATE triggers
|
||||||
|
let updated_cols: HashSet<usize> = 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 Some(index) = table_ref.op.index() else {
|
||||||
let rowid_alias_used = plan.set_clauses.iter().fold(false, |accum, (idx, _)| {
|
let rowid_alias_used = plan.set_clauses.iter().fold(false, |accum, (idx, _)| {
|
||||||
accum || (*idx != ROWID_SENTINEL && btree_table.columns[*idx].is_rowid_alias())
|
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)
|
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
|
/// An ephemeral table is required if:
|
||||||
/// btree used to iterate over the table.
|
/// 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 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.
|
/// 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,
|
/// 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.
|
/// and then the temp table will be iterated over and the actual row updates performed.
|
||||||
|
|||||||
@@ -259,6 +259,12 @@ pub enum QueryDestination {
|
|||||||
/// The number of registers that hold the result of the subquery.
|
/// The number of registers that hold the result of the subquery.
|
||||||
num_regs: usize,
|
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.
|
/// Decision made at some point after query plan construction.
|
||||||
Unset,
|
Unset,
|
||||||
}
|
}
|
||||||
@@ -474,6 +480,11 @@ pub struct DeletePlan {
|
|||||||
pub contains_constant_false_condition: bool,
|
pub contains_constant_false_condition: bool,
|
||||||
/// Indexes that must be updated by the delete operation.
|
/// Indexes that must be updated by the delete operation.
|
||||||
pub indexes: Vec<Arc<Index>>,
|
pub indexes: Vec<Arc<Index>>,
|
||||||
|
/// 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<SelectPlan>,
|
||||||
|
/// Register ID for the RowSet (if rowset_plan is Some)
|
||||||
|
pub rowset_reg: Option<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -1004,10 +1015,10 @@ impl JoinedTable {
|
|||||||
} else {
|
} else {
|
||||||
CursorType::BTreeTable(btree.clone())
|
CursorType::BTreeTable(btree.clone())
|
||||||
};
|
};
|
||||||
Some(
|
Some(program.alloc_cursor_id_keyed_if_not_exists(
|
||||||
program
|
CursorKey::table(self.internal_id),
|
||||||
.alloc_cursor_id_keyed(CursorKey::table(self.internal_id), cursor_type),
|
cursor_type,
|
||||||
)
|
))
|
||||||
};
|
};
|
||||||
|
|
||||||
let index_cursor_id = index
|
let index_cursor_id = index
|
||||||
|
|||||||
@@ -160,6 +160,18 @@ pub fn emit_result_row_and_limit(
|
|||||||
extra_amount: num_regs - 1,
|
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"),
|
QueryDestination::Unset => unreachable!("Unset query destination should not be reached"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -307,6 +307,7 @@ pub enum SchemaEntryType {
|
|||||||
Table,
|
Table,
|
||||||
Index,
|
Index,
|
||||||
View,
|
View,
|
||||||
|
Trigger,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SchemaEntryType {
|
impl SchemaEntryType {
|
||||||
@@ -315,6 +316,7 @@ impl SchemaEntryType {
|
|||||||
SchemaEntryType::Table => "table",
|
SchemaEntryType::Table => "table",
|
||||||
SchemaEntryType::Index => "index",
|
SchemaEntryType::Index => "index",
|
||||||
SchemaEntryType::View => "view",
|
SchemaEntryType::View => "view",
|
||||||
|
SchemaEntryType::Trigger => "trigger",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -677,7 +679,7 @@ pub fn translate_drop_table(
|
|||||||
let table_reg =
|
let table_reg =
|
||||||
program.emit_string8_new_reg(normalize_ident(tbl_name.name.as_str()).to_string()); // r3
|
program.emit_string8_new_reg(normalize_ident(tbl_name.name.as_str()).to_string()); // r3
|
||||||
program.mark_last_insn_constant();
|
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();
|
program.mark_last_insn_constant();
|
||||||
let row_id_reg = program.alloc_register(); // r5
|
let row_id_reg = program.alloc_register(); // r5
|
||||||
|
|
||||||
@@ -692,7 +694,7 @@ pub fn translate_drop_table(
|
|||||||
db: 0,
|
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
|
// loop to beginning of schema table
|
||||||
let end_metadata_label = program.allocate_label();
|
let end_metadata_label = program.allocate_label();
|
||||||
let metadata_loop = program.allocate_label();
|
let metadata_loop = program.allocate_label();
|
||||||
@@ -716,18 +718,6 @@ pub fn translate_drop_table(
|
|||||||
flags: CmpInsFlags::default(),
|
flags: CmpInsFlags::default(),
|
||||||
collation: program.curr_collation(),
|
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 {
|
program.emit_insn(Insn::RowId {
|
||||||
cursor_id: sqlite_schema_cursor_id_0,
|
cursor_id: sqlite_schema_cursor_id_0,
|
||||||
dest: row_id_reg,
|
dest: row_id_reg,
|
||||||
|
|||||||
293
core/translate/trigger.rs
Normal file
293
core/translate/trigger.rs
Normal file
@@ -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<ast::TriggerTime>,
|
||||||
|
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<ast::TriggerTime>,
|
||||||
|
tbl_name: QualifiedName,
|
||||||
|
mut program: ProgramBuilder,
|
||||||
|
sql: String,
|
||||||
|
) -> Result<ProgramBuilder> {
|
||||||
|
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<ProgramBuilder> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
812
core/translate/trigger_exec.rs
Normal file
812
core/translate/trigger_exec.rs
Normal file
@@ -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<BTreeTable>,
|
||||||
|
/// NEW row registers (for INSERT/UPDATE). The last element is always the rowid.
|
||||||
|
pub new_registers: Option<Vec<usize>>,
|
||||||
|
/// OLD row registers (for UPDATE/DELETE). The last element is always the rowid.
|
||||||
|
pub old_registers: Option<Vec<usize>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TriggerContext {
|
||||||
|
pub fn new(
|
||||||
|
table: Arc<BTreeTable>,
|
||||||
|
new_registers: Option<Vec<usize>>,
|
||||||
|
old_registers: Option<Vec<usize>>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
table,
|
||||||
|
new_registers,
|
||||||
|
old_registers,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct ParamMap(Vec<NonZero<usize>>);
|
||||||
|
|
||||||
|
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<ParamMap>,
|
||||||
|
/// Map from column index to parameter index for OLD values (1-indexed)
|
||||||
|
old_param_map: Option<ParamMap>,
|
||||||
|
table: Arc<BTreeTable>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TriggerSubprogramContext {
|
||||||
|
pub fn get_new_param(&self, idx: usize) -> Option<NonZero<usize>> {
|
||||||
|
self.new_param_map
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|map| map.0.get(idx).copied())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_new_rowid_param(&self) -> Option<NonZero<usize>> {
|
||||||
|
self.new_param_map
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|map| map.0.last().copied())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_old_param(&self, idx: usize) -> Option<NonZero<usize>> {
|
||||||
|
self.old_param_map
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|map| map.0.get(idx).copied())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_old_rowid_param(&self) -> Option<NonZero<usize>> {
|
||||||
|
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<WalkControl> {
|
||||||
|
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<ast::Stmt> {
|
||||||
|
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<Trigger>,
|
||||||
|
ctx: &TriggerContext,
|
||||||
|
connection: &Arc<crate::Connection>,
|
||||||
|
) -> Result<bool> {
|
||||||
|
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<usize>>,
|
||||||
|
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<HashSet<usize>>,
|
||||||
|
table: &'a BTreeTable,
|
||||||
|
) -> impl Iterator<Item = Arc<Trigger>> + '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<Trigger>,
|
||||||
|
ctx: &TriggerContext,
|
||||||
|
connection: &Arc<crate::Connection>,
|
||||||
|
) -> 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<WalkControl> {
|
||||||
|
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(())
|
||||||
|
}
|
||||||
@@ -34,6 +34,9 @@ pub fn emit_values(
|
|||||||
QueryDestination::RowValueSubqueryResult { .. } => {
|
QueryDestination::RowValueSubqueryResult { .. } => {
|
||||||
emit_toplevel_values(program, plan, t_ctx)?
|
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"),
|
QueryDestination::Unset => unreachable!("Unset query destination should not be reached"),
|
||||||
};
|
};
|
||||||
Ok(reg_result_cols_start)
|
Ok(reg_result_cols_start)
|
||||||
@@ -212,6 +215,9 @@ fn emit_values_to_destination(
|
|||||||
extra_amount: num_regs - 1,
|
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"),
|
QueryDestination::Unset => unreachable!("Unset query destination should not be reached"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
|
use parking_lot::RwLock;
|
||||||
use std::{
|
use std::{
|
||||||
cmp::Ordering,
|
cmp::Ordering,
|
||||||
sync::{atomic::AtomicI64, Arc},
|
sync::{atomic::AtomicI64, Arc},
|
||||||
};
|
};
|
||||||
|
|
||||||
use tracing::{instrument, Level};
|
use tracing::{instrument, Level};
|
||||||
use turso_parser::ast::{self, TableInternalId};
|
use turso_parser::ast::{self, ResolveType, TableInternalId};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
index_method::IndexMethodAttachment,
|
index_method::IndexMethodAttachment,
|
||||||
numeric::Numeric,
|
numeric::Numeric,
|
||||||
parameters::Parameters,
|
parameters::Parameters,
|
||||||
schema::{BTreeTable, Index, PseudoCursorType, Schema, Table},
|
schema::{BTreeTable, Index, PseudoCursorType, Schema, Table, Trigger},
|
||||||
translate::{
|
translate::{
|
||||||
collate::CollationSeq,
|
collate::CollationSeq,
|
||||||
emitter::TransactionMode,
|
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.
|
/// A key that uniquely identifies a cursor.
|
||||||
/// The key is a pair of table reference id and index.
|
/// The key is a pair of table reference id and index.
|
||||||
@@ -89,7 +90,7 @@ pub struct ProgramBuilder {
|
|||||||
next_free_register: usize,
|
next_free_register: usize,
|
||||||
next_free_cursor_id: usize,
|
next_free_cursor_id: usize,
|
||||||
/// Instruction, the function to execute it with, and its original index in the vector.
|
/// 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),
|
/// 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
|
/// 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.
|
/// 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.
|
/// i.e. the individual statement may need to be aborted due to a constraint conflict, etc.
|
||||||
/// instead of the entire transaction.
|
/// instead of the entire transaction.
|
||||||
needs_stmt_subtransactions: bool,
|
needs_stmt_subtransactions: bool,
|
||||||
|
/// If this ProgramBuilder is building trigger subprogram, a ref to the trigger is stored here.
|
||||||
|
pub trigger: Option<Arc<Trigger>>,
|
||||||
|
pub resolve_type: ResolveType,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -189,6 +193,22 @@ impl ProgramBuilder {
|
|||||||
query_mode: QueryMode,
|
query_mode: QueryMode,
|
||||||
capture_data_changes_mode: CaptureDataChangesMode,
|
capture_data_changes_mode: CaptureDataChangesMode,
|
||||||
opts: ProgramBuilderOpts,
|
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<Trigger>,
|
||||||
|
) -> 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<Arc<Trigger>>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
table_reference_counter: TableRefIdCounter::new(),
|
table_reference_counter: TableRefIdCounter::new(),
|
||||||
@@ -215,9 +235,15 @@ impl ProgramBuilder {
|
|||||||
current_parent_explain_idx: None,
|
current_parent_explain_idx: None,
|
||||||
reg_result_cols_start: None,
|
reg_result_cols_start: None,
|
||||||
needs_stmt_subtransactions: false,
|
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) {
|
pub fn set_needs_stmt_subtransactions(&mut self, needs_stmt_subtransactions: bool) {
|
||||||
self.needs_stmt_subtransactions = needs_stmt_subtransactions;
|
self.needs_stmt_subtransactions = needs_stmt_subtransactions;
|
||||||
}
|
}
|
||||||
@@ -829,6 +855,9 @@ impl ProgramBuilder {
|
|||||||
Insn::VFilter { pc_if_empty, .. } => {
|
Insn::VFilter { pc_if_empty, .. } => {
|
||||||
resolve(pc_if_empty, "VFilter");
|
resolve(pc_if_empty, "VFilter");
|
||||||
}
|
}
|
||||||
|
Insn::RowSetRead { pc_if_empty, .. } => {
|
||||||
|
resolve(pc_if_empty, "RowSetRead");
|
||||||
|
}
|
||||||
Insn::NoConflict { target_pc, .. } => {
|
Insn::NoConflict { target_pc, .. } => {
|
||||||
resolve(target_pc, "NoConflict");
|
resolve(target_pc, "NoConflict");
|
||||||
}
|
}
|
||||||
@@ -923,6 +952,15 @@ impl ProgramBuilder {
|
|||||||
|
|
||||||
/// Initialize the program with basic setup and return initial metadata and labels
|
/// Initialize the program with basic setup and return initial metadata and labels
|
||||||
pub fn prologue(&mut self) {
|
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 {
|
if self.nested_level == 0 {
|
||||||
self.init_label = self.allocate_label();
|
self.init_label = self.allocate_label();
|
||||||
|
|
||||||
@@ -961,6 +999,13 @@ impl ProgramBuilder {
|
|||||||
/// Note that although these are the final instructions, typically an SQLite
|
/// Note that although these are the final instructions, typically an SQLite
|
||||||
/// query will jump to the Transaction instruction via init_label.
|
/// query will jump to the Transaction instruction via init_label.
|
||||||
pub fn epilogue(&mut self, schema: &Schema) {
|
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 {
|
if self.nested_level == 0 {
|
||||||
// "rollback" flag is used to determine if halt should rollback the transaction.
|
// "rollback" flag is used to determine if halt should rollback the transaction.
|
||||||
self.emit_halt(self.rollback);
|
self.emit_halt(self.rollback);
|
||||||
@@ -1110,6 +1155,9 @@ impl ProgramBuilder {
|
|||||||
sql: sql.to_string(),
|
sql: sql.to_string(),
|
||||||
accesses_db: !matches!(self.txn_mode, TransactionMode::None),
|
accesses_db: !matches!(self.txn_mode, TransactionMode::None),
|
||||||
needs_stmt_subtransactions: self.needs_stmt_subtransactions,
|
needs_stmt_subtransactions: self.needs_stmt_subtransactions,
|
||||||
|
trigger: self.trigger.take(),
|
||||||
|
resolve_type: self.resolve_type,
|
||||||
|
explain_state: RwLock::new(ExplainState::default()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ use crate::util::{
|
|||||||
use crate::vdbe::affinity::{apply_numeric_affinity, try_for_float, Affinity, ParsedNumber};
|
use crate::vdbe::affinity::{apply_numeric_affinity, try_for_float, Affinity, ParsedNumber};
|
||||||
use crate::vdbe::insn::InsertFlags;
|
use crate::vdbe::insn::InsertFlags;
|
||||||
use crate::vdbe::value::ComparisonOp;
|
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::vector::{vector32_sparse, vector_concat, vector_distance_jaccard, vector_slice};
|
||||||
use crate::{
|
use crate::{
|
||||||
error::{
|
error::{
|
||||||
@@ -39,13 +39,14 @@ use crate::{
|
|||||||
},
|
},
|
||||||
translate::emitter::TransactionMode,
|
translate::emitter::TransactionMode,
|
||||||
};
|
};
|
||||||
use crate::{get_cursor, CheckpointMode, Connection, DatabaseStorage, MvCursor};
|
use crate::{get_cursor, CheckpointMode, Completion, Connection, DatabaseStorage, MvCursor};
|
||||||
use either::Either;
|
use either::Either;
|
||||||
use std::any::Any;
|
use std::any::Any;
|
||||||
use std::env::temp_dir;
|
use std::env::temp_dir;
|
||||||
use std::ops::DerefMut;
|
use std::ops::DerefMut;
|
||||||
use std::{
|
use std::{
|
||||||
borrow::BorrowMut,
|
borrow::BorrowMut,
|
||||||
|
num::NonZero,
|
||||||
sync::{atomic::Ordering, Arc, Mutex},
|
sync::{atomic::Ordering, Arc, Mutex},
|
||||||
};
|
};
|
||||||
use turso_macros::match_ignore_ascii_case;
|
use turso_macros::match_ignore_ascii_case;
|
||||||
@@ -75,7 +76,7 @@ use super::{
|
|||||||
CommitState,
|
CommitState,
|
||||||
};
|
};
|
||||||
use parking_lot::RwLock;
|
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 turso_parser::parser::Parser;
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
@@ -2068,8 +2069,6 @@ pub fn halt(
|
|||||||
description: &str,
|
description: &str,
|
||||||
) -> Result<InsnFunctionStepResult> {
|
) -> Result<InsnFunctionStepResult> {
|
||||||
if err_code > 0 {
|
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)?;
|
vtab_rollback_all(&program.connection, state)?;
|
||||||
}
|
}
|
||||||
match err_code {
|
match err_code {
|
||||||
@@ -2107,12 +2106,15 @@ pub fn halt(
|
|||||||
.load(Ordering::Acquire)
|
.load(Ordering::Acquire)
|
||||||
> 0
|
> 0
|
||||||
{
|
{
|
||||||
state.end_statement(&program.connection, pager, EndStatement::RollbackSavepoint)?;
|
|
||||||
return Err(LimboError::Constraint(
|
return Err(LimboError::Constraint(
|
||||||
"foreign key constraint failed".to_string(),
|
"foreign key constraint failed".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if program.is_trigger_subprogram() {
|
||||||
|
return Ok(InsnFunctionStepResult::Done);
|
||||||
|
}
|
||||||
|
|
||||||
if auto_commit {
|
if auto_commit {
|
||||||
// In autocommit mode, a statement that leaves deferred violations must fail here,
|
// In autocommit mode, a statement that leaves deferred violations must fail here,
|
||||||
// and it also ends the transaction.
|
// and it also ends the transaction.
|
||||||
@@ -2256,6 +2258,11 @@ pub fn op_transaction_inner(
|
|||||||
},
|
},
|
||||||
insn
|
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);
|
let pager = program.get_pager_from_database_index(db);
|
||||||
loop {
|
loop {
|
||||||
match state.op_transaction_state {
|
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
|
let res = program
|
||||||
.commit_txn(pager.clone(), state, mv_store, requested_rollback)
|
.commit_txn(pager.clone(), state, mv_store, requested_rollback)
|
||||||
.map(Into::into);
|
.map(Into::into);
|
||||||
@@ -2643,6 +2656,100 @@ pub fn op_integer(
|
|||||||
Ok(InsnFunctionStepResult::Step)
|
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<Pager>,
|
||||||
|
mv_store: Option<&Arc<MvStore>>,
|
||||||
|
) -> Result<InsnFunctionStepResult> {
|
||||||
|
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::<usize>::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(
|
pub fn op_real(
|
||||||
program: &Program,
|
program: &Program,
|
||||||
state: &mut ProgramState,
|
state: &mut ProgramState,
|
||||||
@@ -6570,7 +6677,6 @@ pub fn op_idx_delete(
|
|||||||
insn
|
insn
|
||||||
);
|
);
|
||||||
|
|
||||||
tracing::info!("idx_delete cursor: {:?}", program.cursor_ref[*cursor_id]);
|
|
||||||
if let Some(Cursor::IndexMethod(cursor)) = &mut state.cursors[*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]));
|
return_if_io!(cursor.delete(&state.registers[*start_reg..*start_reg + *num_regs]));
|
||||||
state.pc += 1;
|
state.pc += 1;
|
||||||
@@ -7596,6 +7702,24 @@ pub fn op_drop_view(
|
|||||||
Ok(InsnFunctionStepResult::Step)
|
Ok(InsnFunctionStepResult::Step)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn op_drop_trigger(
|
||||||
|
program: &Program,
|
||||||
|
state: &mut ProgramState,
|
||||||
|
insn: &Insn,
|
||||||
|
pager: &Arc<Pager>,
|
||||||
|
mv_store: Option<&Arc<MvStore>>,
|
||||||
|
) -> Result<InsnFunctionStepResult> {
|
||||||
|
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(
|
pub fn op_close(
|
||||||
program: &Program,
|
program: &Program,
|
||||||
state: &mut ProgramState,
|
state: &mut ProgramState,
|
||||||
@@ -7606,6 +7730,9 @@ pub fn op_close(
|
|||||||
load_insn!(Close { cursor_id }, insn);
|
load_insn!(Close { cursor_id }, insn);
|
||||||
let cursors = &mut state.cursors;
|
let cursors = &mut state.cursors;
|
||||||
cursors.get_mut(*cursor_id).unwrap().take();
|
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;
|
state.pc += 1;
|
||||||
Ok(InsnFunctionStepResult::Step)
|
Ok(InsnFunctionStepResult::Step)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -727,6 +727,23 @@ pub fn insn_to_row(
|
|||||||
0,
|
0,
|
||||||
format!("r[{dest}]={value}"),
|
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 } => (
|
Insn::Real { value, dest } => (
|
||||||
"Real",
|
"Real",
|
||||||
0,
|
0,
|
||||||
@@ -1407,6 +1424,15 @@ pub fn insn_to_row(
|
|||||||
0,
|
0,
|
||||||
format!("DROP TABLE {table_name}"),
|
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 } => (
|
Insn::DropView { db, view_name } => (
|
||||||
"DropView",
|
"DropView",
|
||||||
*db as i32,
|
*db as i32,
|
||||||
|
|||||||
@@ -10,8 +10,9 @@ use crate::{
|
|||||||
translate::{collate::CollationSeq, emitter::TransactionMode},
|
translate::{collate::CollationSeq, emitter::TransactionMode},
|
||||||
types::KeyInfo,
|
types::KeyInfo,
|
||||||
vdbe::affinity::Affinity,
|
vdbe::affinity::Affinity,
|
||||||
Value,
|
Statement, Value,
|
||||||
};
|
};
|
||||||
|
use parking_lot::RwLock;
|
||||||
use strum::EnumCount;
|
use strum::EnumCount;
|
||||||
use strum_macros::{EnumDiscriminants, FromRepr, VariantArray};
|
use strum_macros::{EnumDiscriminants, FromRepr, VariantArray};
|
||||||
use turso_macros::Description;
|
use turso_macros::Description;
|
||||||
@@ -530,6 +531,18 @@ pub enum Insn {
|
|||||||
can_fallthrough: bool,
|
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<Value>,
|
||||||
|
program: Arc<RwLock<Statement>>,
|
||||||
|
},
|
||||||
|
|
||||||
/// Write an integer value into a register.
|
/// Write an integer value into a register.
|
||||||
Integer {
|
Integer {
|
||||||
value: i64,
|
value: i64,
|
||||||
@@ -980,6 +993,13 @@ pub enum Insn {
|
|||||||
// The name of the index being dropped
|
// The name of the index being dropped
|
||||||
index: Arc<Index>,
|
index: Arc<Index>,
|
||||||
},
|
},
|
||||||
|
/// 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 a cursor.
|
||||||
Close {
|
Close {
|
||||||
@@ -1329,6 +1349,7 @@ impl InsnVariants {
|
|||||||
InsnVariants::Gosub => execute::op_gosub,
|
InsnVariants::Gosub => execute::op_gosub,
|
||||||
InsnVariants::Return => execute::op_return,
|
InsnVariants::Return => execute::op_return,
|
||||||
InsnVariants::Integer => execute::op_integer,
|
InsnVariants::Integer => execute::op_integer,
|
||||||
|
InsnVariants::Program => execute::op_program,
|
||||||
InsnVariants::Real => execute::op_real,
|
InsnVariants::Real => execute::op_real,
|
||||||
InsnVariants::RealAffinity => execute::op_real_affinity,
|
InsnVariants::RealAffinity => execute::op_real_affinity,
|
||||||
InsnVariants::String8 => execute::op_string8,
|
InsnVariants::String8 => execute::op_string8,
|
||||||
@@ -1383,6 +1404,7 @@ impl InsnVariants {
|
|||||||
InsnVariants::Destroy => execute::op_destroy,
|
InsnVariants::Destroy => execute::op_destroy,
|
||||||
InsnVariants::ResetSorter => execute::op_reset_sorter,
|
InsnVariants::ResetSorter => execute::op_reset_sorter,
|
||||||
InsnVariants::DropTable => execute::op_drop_table,
|
InsnVariants::DropTable => execute::op_drop_table,
|
||||||
|
InsnVariants::DropTrigger => execute::op_drop_trigger,
|
||||||
InsnVariants::DropView => execute::op_drop_view,
|
InsnVariants::DropView => execute::op_drop_view,
|
||||||
InsnVariants::Close => execute::op_close,
|
InsnVariants::Close => execute::op_close,
|
||||||
InsnVariants::IsNull => execute::op_is_null,
|
InsnVariants::IsNull => execute::op_is_null,
|
||||||
|
|||||||
160
core/vdbe/mod.rs
160
core/vdbe/mod.rs
@@ -33,6 +33,7 @@ use crate::{
|
|||||||
function::{AggFunc, FuncCtx},
|
function::{AggFunc, FuncCtx},
|
||||||
mvcc::{database::CommitStateMachine, LocalClock},
|
mvcc::{database::CommitStateMachine, LocalClock},
|
||||||
return_if_io,
|
return_if_io,
|
||||||
|
schema::Trigger,
|
||||||
state_machine::StateMachine,
|
state_machine::StateMachine,
|
||||||
storage::{pager::PagerCommitResult, sqlite3_ondisk::SmallVec},
|
storage::{pager::PagerCommitResult, sqlite3_ondisk::SmallVec},
|
||||||
translate::{collate::CollationSeq, plan::TableReferences},
|
translate::{collate::CollationSeq, plan::TableReferences},
|
||||||
@@ -41,7 +42,7 @@ use crate::{
|
|||||||
execute::{
|
execute::{
|
||||||
OpCheckpointState, OpColumnState, OpDeleteState, OpDeleteSubState, OpDestroyState,
|
OpCheckpointState, OpColumnState, OpDeleteState, OpDeleteSubState, OpDestroyState,
|
||||||
OpIdxInsertState, OpInsertState, OpInsertSubState, OpNewRowidState, OpNoConflictState,
|
OpIdxInsertState, OpInsertState, OpInsertSubState, OpNewRowidState, OpNoConflictState,
|
||||||
OpRowIdState, OpSeekState, OpTransactionState,
|
OpProgramState, OpRowIdState, OpSeekState, OpTransactionState,
|
||||||
},
|
},
|
||||||
metrics::StatementMetrics,
|
metrics::StatementMetrics,
|
||||||
},
|
},
|
||||||
@@ -63,6 +64,8 @@ use execute::{
|
|||||||
InsnFunction, InsnFunctionStepResult, OpIdxDeleteState, OpIntegrityCheckState,
|
InsnFunction, InsnFunctionStepResult, OpIdxDeleteState, OpIntegrityCheckState,
|
||||||
OpOpenEphemeralState,
|
OpOpenEphemeralState,
|
||||||
};
|
};
|
||||||
|
use parking_lot::RwLock;
|
||||||
|
use turso_parser::ast::ResolveType;
|
||||||
|
|
||||||
use crate::vdbe::rowset::RowSet;
|
use crate::vdbe::rowset::RowSet;
|
||||||
use explain::{insn_to_row_with_comment, EXPLAIN_COLUMNS, EXPLAIN_QUERY_PLAN_COLUMNS};
|
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
|
/// Metrics collected during statement execution
|
||||||
pub metrics: StatementMetrics,
|
pub metrics: StatementMetrics,
|
||||||
op_open_ephemeral_state: OpOpenEphemeralState,
|
op_open_ephemeral_state: OpOpenEphemeralState,
|
||||||
|
op_program_state: OpProgramState,
|
||||||
op_new_rowid_state: OpNewRowidState,
|
op_new_rowid_state: OpNewRowidState,
|
||||||
op_idx_insert_state: OpIdxInsertState,
|
op_idx_insert_state: OpIdxInsertState,
|
||||||
op_insert_state: OpInsertState,
|
op_insert_state: OpInsertState,
|
||||||
@@ -324,6 +328,12 @@ pub struct ProgramState {
|
|||||||
rowsets: HashMap<usize, RowSet>,
|
rowsets: HashMap<usize, RowSet>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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.
|
// SAFETY: This needs to be audited for thread safety.
|
||||||
// See: https://github.com/tursodatabase/turso/issues/1552
|
// See: https://github.com/tursodatabase/turso/issues/1552
|
||||||
unsafe impl Send for ProgramState {}
|
unsafe impl Send for ProgramState {}
|
||||||
@@ -360,6 +370,7 @@ impl ProgramState {
|
|||||||
op_integrity_check_state: OpIntegrityCheckState::Start,
|
op_integrity_check_state: OpIntegrityCheckState::Start,
|
||||||
metrics: StatementMetrics::new(),
|
metrics: StatementMetrics::new(),
|
||||||
op_open_ephemeral_state: OpOpenEphemeralState::Start,
|
op_open_ephemeral_state: OpOpenEphemeralState::Start,
|
||||||
|
op_program_state: OpProgramState::Start,
|
||||||
op_new_rowid_state: OpNewRowidState::Start,
|
op_new_rowid_state: OpNewRowidState::Start,
|
||||||
op_idx_insert_state: OpIdxInsertState::MaybeSeek,
|
op_idx_insert_state: OpIdxInsertState::MaybeSeek,
|
||||||
op_insert_state: OpInsertState {
|
op_insert_state: OpInsertState {
|
||||||
@@ -515,7 +526,12 @@ impl ProgramState {
|
|||||||
match end_statement {
|
match end_statement {
|
||||||
EndStatement::ReleaseSavepoint => pager.release_savepoint(),
|
EndStatement::ReleaseSavepoint => pager.release_savepoint(),
|
||||||
EndStatement::RollbackSavepoint => {
|
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.
|
// 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.
|
// This is used to ensure that if an interactive transaction had deferred FK violations, they are not lost.
|
||||||
connection.fk_deferred_violations.store(
|
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<usize>,
|
||||||
|
/// Index of the subprogram currently being processed, if any.
|
||||||
|
current_subprogram_index: Option<usize>,
|
||||||
|
/// PC value when we started processing the current subprogram, to detect if we need to reset.
|
||||||
|
subprogram_start_pc: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct Program {
|
pub struct Program {
|
||||||
pub max_registers: usize,
|
pub max_registers: usize,
|
||||||
// we store original indices because we don't want to create new vec from
|
// 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
|
/// 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.
|
/// statement may need to be aborted due to a constraint conflict, etc. instead of the entire transaction.
|
||||||
pub needs_stmt_subtransactions: bool,
|
pub needs_stmt_subtransactions: bool,
|
||||||
|
pub trigger: Option<Arc<Trigger>>,
|
||||||
|
pub resolve_type: ResolveType,
|
||||||
|
pub explain_state: RwLock<ExplainState>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Program {
|
impl Program {
|
||||||
@@ -650,11 +680,106 @@ impl Program {
|
|||||||
// FIXME: do we need this?
|
// FIXME: do we need this?
|
||||||
state.metrics.vm_steps = state.metrics.vm_steps.saturating_add(1);
|
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() {
|
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];
|
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(
|
let (opcode, p1, p2, p3, p4, p5, comment) = insn_to_row_with_comment(
|
||||||
self,
|
self,
|
||||||
current_insn,
|
current_insn,
|
||||||
@@ -747,7 +872,7 @@ impl Program {
|
|||||||
return Err(LimboError::InternalError("Connection closed".to_string()));
|
return Err(LimboError::InternalError("Connection closed".to_string()));
|
||||||
}
|
}
|
||||||
if state.is_interrupted() {
|
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);
|
return Ok(StepResult::Interrupt);
|
||||||
}
|
}
|
||||||
if let Some(io) = &state.io_completions {
|
if let Some(io) = &state.io_completions {
|
||||||
@@ -757,7 +882,7 @@ impl Program {
|
|||||||
}
|
}
|
||||||
if let Some(err) = io.get_error() {
|
if let Some(err) = io.get_error() {
|
||||||
let err = err.into();
|
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);
|
return Err(err);
|
||||||
}
|
}
|
||||||
state.io_completions = None;
|
state.io_completions = None;
|
||||||
@@ -799,7 +924,7 @@ impl Program {
|
|||||||
return Ok(StepResult::Busy);
|
return Ok(StepResult::Busy);
|
||||||
}
|
}
|
||||||
Err(err) => {
|
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);
|
return Err(err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1059,10 +1184,21 @@ impl Program {
|
|||||||
mv_store: Option<&Arc<MvStore>>,
|
mv_store: Option<&Arc<MvStore>>,
|
||||||
pager: &Arc<Pager>,
|
pager: &Arc<Pager>,
|
||||||
err: Option<&LimboError>,
|
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.
|
// 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 {
|
match err {
|
||||||
// Transaction errors, e.g. trying to start a nested transaction, do not cause a rollback.
|
// Transaction errors, e.g. trying to start a nested transaction, do not cause a rollback.
|
||||||
Some(LimboError::TxError(_)) => {}
|
Some(LimboError::TxError(_)) => {}
|
||||||
@@ -1075,7 +1211,7 @@ impl Program {
|
|||||||
// and op_halt.
|
// and op_halt.
|
||||||
Some(LimboError::Constraint(_)) => {}
|
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(mv_store) = mv_store {
|
||||||
if let Some(tx_id) = self.connection.get_mv_tx_id() {
|
if let Some(tx_id) = self.connection.get_mv_tx_id() {
|
||||||
self.connection.auto_commit.store(true, Ordering::SeqCst);
|
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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
761
testing/trigger.test
Executable file
761
testing/trigger.test
Executable file
@@ -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}
|
||||||
@@ -651,16 +651,21 @@ mod fuzz_tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[allow(unused_assignments)]
|
#[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 _ = env_logger::try_init();
|
||||||
let (mut rng, seed) = rng_from_time_or_env();
|
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 OUTER_ITERS: usize = 10;
|
||||||
const INNER_ITERS: usize = 100;
|
const INNER_ITERS: usize = 100;
|
||||||
|
|
||||||
for outer in 0..OUTER_ITERS {
|
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 limbo_db = TempDatabase::new_empty();
|
||||||
let sqlite_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
|
// Transaction-based mutations with mix of deferred and immediate operations
|
||||||
let mut in_tx = false;
|
let mut in_tx = false;
|
||||||
for tx_num in 0..INNER_ITERS {
|
for tx_num in 0..INNER_ITERS {
|
||||||
@@ -1856,16 +1954,128 @@ mod fuzz_tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
/// Create a table with a random number of columns and indexes, and then randomly update or delete rows from the table.
|
/// 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.
|
/// Verify that the results are the same for SQLite and Turso.
|
||||||
pub fn table_index_mutation_fuzz() {
|
pub fn table_index_mutation_fuzz() {
|
||||||
|
/// Format a nice diff between two result sets for better error messages
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn format_rows_diff(
|
||||||
|
sqlite_rows: &[Vec<Value>],
|
||||||
|
limbo_rows: &[Vec<Value>],
|
||||||
|
seed: u64,
|
||||||
|
query: &str,
|
||||||
|
table_def: &str,
|
||||||
|
indexes: &[String],
|
||||||
|
trigger: Option<&String>,
|
||||||
|
dml_statements: &[String],
|
||||||
|
) -> String {
|
||||||
|
let mut diff = String::new();
|
||||||
|
let sqlite_rows_len = sqlite_rows.len();
|
||||||
|
let limbo_rows_len = limbo_rows.len();
|
||||||
|
diff.push_str(&format!(
|
||||||
|
"\n\n=== Row Count Difference ===\nSQLite: {sqlite_rows_len} rows, Limbo: {limbo_rows_len} rows\n",
|
||||||
|
));
|
||||||
|
|
||||||
|
// Find rows that differ at the same index
|
||||||
|
let max_len = sqlite_rows.len().max(limbo_rows.len());
|
||||||
|
let mut diff_indices = Vec::new();
|
||||||
|
for i in 0..max_len {
|
||||||
|
let sqlite_row = sqlite_rows.get(i);
|
||||||
|
let limbo_row = limbo_rows.get(i);
|
||||||
|
if sqlite_row != limbo_row {
|
||||||
|
diff_indices.push(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !diff_indices.is_empty() {
|
||||||
|
diff.push_str("\n=== Rows Differing at Same Index (showing first 10) ===\n");
|
||||||
|
for &idx in diff_indices.iter().take(10) {
|
||||||
|
diff.push_str(&format!("\nIndex {idx}:\n"));
|
||||||
|
if let Some(sqlite_row) = sqlite_rows.get(idx) {
|
||||||
|
diff.push_str(&format!(" SQLite: {sqlite_row:?}\n"));
|
||||||
|
} else {
|
||||||
|
diff.push_str(" SQLite: <missing>\n");
|
||||||
|
}
|
||||||
|
if let Some(limbo_row) = limbo_rows.get(idx) {
|
||||||
|
diff.push_str(&format!(" Limbo: {limbo_row:?}\n"));
|
||||||
|
} else {
|
||||||
|
diff.push_str(" Limbo: <missing>\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if diff_indices.len() > 10 {
|
||||||
|
diff.push_str(&format!(
|
||||||
|
"\n... and {} more differences\n",
|
||||||
|
diff_indices.len() - 10
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find rows that are in one but not the other (using linear search since Value doesn't implement Hash)
|
||||||
|
let mut only_in_sqlite = Vec::new();
|
||||||
|
for sqlite_row in sqlite_rows.iter() {
|
||||||
|
if !limbo_rows.iter().any(|limbo_row| limbo_row == sqlite_row) {
|
||||||
|
only_in_sqlite.push(sqlite_row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut only_in_limbo = Vec::new();
|
||||||
|
for limbo_row in limbo_rows.iter() {
|
||||||
|
if !sqlite_rows.iter().any(|sqlite_row| sqlite_row == limbo_row) {
|
||||||
|
only_in_limbo.push(limbo_row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !only_in_sqlite.is_empty() {
|
||||||
|
diff.push_str("\n=== Rows Only in SQLite (showing first 10) ===\n");
|
||||||
|
for row in only_in_sqlite.iter().take(10) {
|
||||||
|
diff.push_str(&format!(" {row:?}\n"));
|
||||||
|
}
|
||||||
|
if only_in_sqlite.len() > 10 {
|
||||||
|
diff.push_str(&format!(
|
||||||
|
"\n... and {} more rows\n",
|
||||||
|
only_in_sqlite.len() - 10
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !only_in_limbo.is_empty() {
|
||||||
|
diff.push_str("\n=== Rows Only in Limbo (showing first 10) ===\n");
|
||||||
|
for row in only_in_limbo.iter().take(10) {
|
||||||
|
diff.push_str(&format!(" {row:?}\n"));
|
||||||
|
}
|
||||||
|
if only_in_limbo.len() > 10 {
|
||||||
|
diff.push_str(&format!(
|
||||||
|
"\n... and {} more rows\n",
|
||||||
|
only_in_limbo.len() - 10
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
diff.push_str(&format!(
|
||||||
|
"\n=== Context ===\nSeed: {seed}\nQuery: {query}\n",
|
||||||
|
));
|
||||||
|
|
||||||
|
diff.push_str("\n=== DDL/DML to Reproduce ===\n");
|
||||||
|
diff.push_str(&format!("{table_def};\n"));
|
||||||
|
for idx in indexes.iter() {
|
||||||
|
diff.push_str(&format!("{idx};\n"));
|
||||||
|
}
|
||||||
|
if let Some(trigger) = trigger {
|
||||||
|
diff.push_str(&format!("{trigger};\n"));
|
||||||
|
}
|
||||||
|
for dml in dml_statements.iter() {
|
||||||
|
diff.push_str(&format!("{dml};\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
diff
|
||||||
|
}
|
||||||
|
|
||||||
let _ = env_logger::try_init();
|
let _ = env_logger::try_init();
|
||||||
let (mut rng, seed) = rng_from_time_or_env();
|
let (mut rng, seed) = rng_from_time_or_env();
|
||||||
println!("table_index_mutation_fuzz seed: {seed}");
|
println!("table_index_mutation_fuzz seed: {seed}");
|
||||||
|
|
||||||
const OUTER_ITERATIONS: usize = 100;
|
const OUTER_ITERATIONS: usize = 30;
|
||||||
for i in 0..OUTER_ITERATIONS {
|
for i in 0..OUTER_ITERATIONS {
|
||||||
println!(
|
println!(
|
||||||
"table_index_mutation_fuzz iteration {}/{}",
|
"table_index_mutation_fuzz iteration {}/{}",
|
||||||
@@ -1926,8 +2136,16 @@ mod fuzz_tests {
|
|||||||
sqlite_conn.execute(t, params![]).unwrap();
|
sqlite_conn.execute(t, params![]).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let use_trigger = rng.random_bool(1.0);
|
||||||
|
|
||||||
// Generate initial data
|
// 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();
|
let mut tuples = HashSet::new();
|
||||||
while tuples.len() < num_inserts {
|
while tuples.len() < num_inserts {
|
||||||
tuples.insert(
|
tuples.insert(
|
||||||
@@ -1953,13 +2171,15 @@ mod fuzz_tests {
|
|||||||
.map(|i| format!("c{i}"))
|
.map(|i| format!("c{i}"))
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(", ");
|
.join(", ");
|
||||||
|
let insert_type = match rng.random_range(0..3) {
|
||||||
|
0 => "",
|
||||||
|
1 => "OR REPLACE",
|
||||||
|
2 => "OR IGNORE",
|
||||||
|
_ => unreachable!(),
|
||||||
|
};
|
||||||
let insert = format!(
|
let insert = format!(
|
||||||
"INSERT {} INTO t ({}) VALUES {}",
|
"INSERT {} INTO t ({}) VALUES {}",
|
||||||
if rng.random_bool(0.4) {
|
insert_type,
|
||||||
"OR IGNORE"
|
|
||||||
} else {
|
|
||||||
""
|
|
||||||
},
|
|
||||||
col_names,
|
col_names,
|
||||||
insert_values.join(", ")
|
insert_values.join(", ")
|
||||||
);
|
);
|
||||||
@@ -1969,6 +2189,145 @@ mod fuzz_tests {
|
|||||||
sqlite_conn.execute(&insert, params![]).unwrap();
|
sqlite_conn.execute(&insert, params![]).unwrap();
|
||||||
limbo_exec_rows(&limbo_db, &limbo_conn, &insert);
|
limbo_exec_rows(&limbo_db, &limbo_conn, &insert);
|
||||||
|
|
||||||
|
// Self-affecting triggers (e.g CREATE TRIGGER t BEFORE DELETE ON t BEGIN UPDATE t ... END) are
|
||||||
|
// an easy source of bugs, so create one some of the time.
|
||||||
|
let trigger = if use_trigger {
|
||||||
|
// Create a random trigger
|
||||||
|
let trigger_time = if rng.random_bool(0.5) {
|
||||||
|
"BEFORE"
|
||||||
|
} else {
|
||||||
|
"AFTER"
|
||||||
|
};
|
||||||
|
let trigger_event = match rng.random_range(0..3) {
|
||||||
|
0 => "INSERT".to_string(),
|
||||||
|
1 => {
|
||||||
|
// Optionally specify columns for UPDATE trigger
|
||||||
|
if rng.random_bool(0.5) {
|
||||||
|
let update_col = rng.random_range(0..num_cols);
|
||||||
|
format!("UPDATE OF c{update_col}")
|
||||||
|
} else {
|
||||||
|
"UPDATE".to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
2 => "DELETE".to_string(),
|
||||||
|
_ => unreachable!(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Determine if OLD/NEW references are available based on trigger event
|
||||||
|
let has_old =
|
||||||
|
trigger_event.starts_with("UPDATE") || trigger_event.starts_with("DELETE");
|
||||||
|
let has_new =
|
||||||
|
trigger_event.starts_with("UPDATE") || trigger_event.starts_with("INSERT");
|
||||||
|
|
||||||
|
// Generate trigger action (INSERT, UPDATE, or DELETE)
|
||||||
|
let trigger_action = match rng.random_range(0..3) {
|
||||||
|
0 => {
|
||||||
|
// INSERT action
|
||||||
|
let values = (0..num_cols)
|
||||||
|
.map(|i| {
|
||||||
|
// Randomly use OLD/NEW values if available
|
||||||
|
if has_old && rng.random_bool(0.3) {
|
||||||
|
format!("OLD.c{i}")
|
||||||
|
} else if has_new && rng.random_bool(0.3) {
|
||||||
|
format!("NEW.c{i}")
|
||||||
|
} else {
|
||||||
|
rng.random_range(0..1000).to_string()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ");
|
||||||
|
let insert_conflict_action = match rng.random_range(0..3) {
|
||||||
|
0 => "",
|
||||||
|
1 => " OR REPLACE",
|
||||||
|
2 => " OR IGNORE",
|
||||||
|
_ => unreachable!(),
|
||||||
|
};
|
||||||
|
format!(
|
||||||
|
"INSERT{insert_conflict_action} INTO t ({col_names}) VALUES ({values})"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
1 => {
|
||||||
|
// UPDATE action
|
||||||
|
let update_col = rng.random_range(0..num_cols);
|
||||||
|
let new_value = if has_old && rng.random_bool(0.3) {
|
||||||
|
let ref_col = rng.random_range(0..num_cols);
|
||||||
|
// Sometimes make it a function of the OLD column
|
||||||
|
if rng.random_bool(0.5) {
|
||||||
|
let operator = *["+", "-", "*"].choose(&mut rng).unwrap();
|
||||||
|
let amount = rng.random_range(1..100);
|
||||||
|
format!("OLD.c{ref_col} {operator} {amount}")
|
||||||
|
} else {
|
||||||
|
format!("OLD.c{ref_col}")
|
||||||
|
}
|
||||||
|
} else if has_new && rng.random_bool(0.3) {
|
||||||
|
let ref_col = rng.random_range(0..num_cols);
|
||||||
|
// Sometimes make it a function of the NEW column
|
||||||
|
if rng.random_bool(0.5) {
|
||||||
|
let operator = *["+", "-", "*"].choose(&mut rng).unwrap();
|
||||||
|
let amount = rng.random_range(1..100);
|
||||||
|
format!("NEW.c{ref_col} {operator} {amount}")
|
||||||
|
} else {
|
||||||
|
format!("NEW.c{ref_col}")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
rng.random_range(0..1000).to_string()
|
||||||
|
};
|
||||||
|
let op = match rng.random_range(0..=3) {
|
||||||
|
0 => "<",
|
||||||
|
1 => "<=",
|
||||||
|
2 => ">",
|
||||||
|
3 => ">=",
|
||||||
|
_ => unreachable!(),
|
||||||
|
};
|
||||||
|
let threshold = if has_old && rng.random_bool(0.3) {
|
||||||
|
let ref_col = rng.random_range(0..num_cols);
|
||||||
|
format!("OLD.c{ref_col}")
|
||||||
|
} else if has_new && rng.random_bool(0.3) {
|
||||||
|
let ref_col = rng.random_range(0..num_cols);
|
||||||
|
format!("NEW.c{ref_col}")
|
||||||
|
} else {
|
||||||
|
rng.random_range(0..1000).to_string()
|
||||||
|
};
|
||||||
|
format!("UPDATE t SET c{update_col} = {new_value} WHERE c{update_col} {op} {threshold}")
|
||||||
|
}
|
||||||
|
2 => {
|
||||||
|
// DELETE action
|
||||||
|
let delete_col = rng.random_range(0..num_cols);
|
||||||
|
let op = match rng.random_range(0..=3) {
|
||||||
|
0 => "<",
|
||||||
|
1 => "<=",
|
||||||
|
2 => ">",
|
||||||
|
3 => ">=",
|
||||||
|
_ => unreachable!(),
|
||||||
|
};
|
||||||
|
let threshold = if has_old && rng.random_bool(0.3) {
|
||||||
|
let ref_col = rng.random_range(0..num_cols);
|
||||||
|
format!("OLD.c{ref_col}")
|
||||||
|
} else if has_new && rng.random_bool(0.3) {
|
||||||
|
let ref_col = rng.random_range(0..num_cols);
|
||||||
|
format!("NEW.c{ref_col}")
|
||||||
|
} else {
|
||||||
|
rng.random_range(0..1000).to_string()
|
||||||
|
};
|
||||||
|
format!("DELETE FROM t WHERE c{delete_col} {op} {threshold}")
|
||||||
|
}
|
||||||
|
_ => unreachable!(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let create_trigger = format!(
|
||||||
|
"CREATE TRIGGER test_trigger {trigger_time} {trigger_event} ON t BEGIN {trigger_action}; END;",
|
||||||
|
);
|
||||||
|
|
||||||
|
sqlite_conn.execute(&create_trigger, params![]).unwrap();
|
||||||
|
limbo_exec_rows(&limbo_db, &limbo_conn, &create_trigger);
|
||||||
|
Some(create_trigger)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
if let Some(ref trigger) = trigger {
|
||||||
|
println!("{trigger};");
|
||||||
|
}
|
||||||
|
|
||||||
const COMPARISONS: [&str; 3] = ["=", "<", ">"];
|
const COMPARISONS: [&str; 3] = ["=", "<", ">"];
|
||||||
const INNER_ITERATIONS: usize = 20;
|
const INNER_ITERATIONS: usize = 20;
|
||||||
|
|
||||||
@@ -1976,7 +2335,6 @@ mod fuzz_tests {
|
|||||||
let do_update = rng.random_range(0..2) == 0;
|
let do_update = rng.random_range(0..2) == 0;
|
||||||
|
|
||||||
let comparison = COMPARISONS[rng.random_range(0..COMPARISONS.len())];
|
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_col = rng.random_range(0..num_cols);
|
||||||
let predicate_value = rng.random_range(0..1000);
|
let predicate_value = rng.random_range(0..1000);
|
||||||
|
|
||||||
@@ -2000,6 +2358,7 @@ mod fuzz_tests {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let query = if do_update {
|
let query = if do_update {
|
||||||
|
let affected_col = rng.random_range(0..num_cols);
|
||||||
let num_updates = rng.random_range(1..=num_cols);
|
let num_updates = rng.random_range(1..=num_cols);
|
||||||
let mut values = Vec::new();
|
let mut values = Vec::new();
|
||||||
for _ in 0..num_updates {
|
for _ in 0..num_updates {
|
||||||
@@ -2048,10 +2407,19 @@ mod fuzz_tests {
|
|||||||
let sqlite_rows = sqlite_exec_rows(&sqlite_conn, &verify_query);
|
let sqlite_rows = sqlite_exec_rows(&sqlite_conn, &verify_query);
|
||||||
let limbo_rows = limbo_exec_rows(&limbo_db, &limbo_conn, &verify_query);
|
let limbo_rows = limbo_exec_rows(&limbo_db, &limbo_conn, &verify_query);
|
||||||
|
|
||||||
assert_eq!(
|
if sqlite_rows != limbo_rows {
|
||||||
sqlite_rows, limbo_rows,
|
let diff_msg = format_rows_diff(
|
||||||
"Different results after mutation! limbo: {limbo_rows:?}, sqlite: {sqlite_rows:?}, seed: {seed}, query: {query}",
|
&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
|
// Run integrity check on limbo db using rusqlite
|
||||||
if let Err(e) = rusqlite_integrity_check(&limbo_db.path) {
|
if let Err(e) = rusqlite_integrity_check(&limbo_db.path) {
|
||||||
@@ -2059,6 +2427,9 @@ mod fuzz_tests {
|
|||||||
for t in indexes.iter() {
|
for t in indexes.iter() {
|
||||||
println!("{t};");
|
println!("{t};");
|
||||||
}
|
}
|
||||||
|
if let Some(trigger) = trigger {
|
||||||
|
println!("{trigger};");
|
||||||
|
}
|
||||||
for t in dml_statements.iter() {
|
for t in dml_statements.iter() {
|
||||||
println!("{t};");
|
println!("{t};");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ mod index_method;
|
|||||||
mod pragma;
|
mod pragma;
|
||||||
mod query_processing;
|
mod query_processing;
|
||||||
mod storage;
|
mod storage;
|
||||||
|
mod trigger;
|
||||||
mod wal;
|
mod wal;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
888
tests/integration/trigger.rs
Normal file
888
tests/integration/trigger.rs
Normal file
@@ -0,0 +1,888 @@
|
|||||||
|
use crate::common::TempDatabase;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_create_trigger() {
|
||||||
|
let _ = tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
|
||||||
|
.try_init();
|
||||||
|
|
||||||
|
let db = TempDatabase::new_empty();
|
||||||
|
let conn = db.connect_limbo();
|
||||||
|
|
||||||
|
conn.execute("CREATE TABLE test (x, y TEXT)").unwrap();
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TRIGGER t1 BEFORE INSERT ON test BEGIN
|
||||||
|
INSERT INTO test VALUES (100, 'triggered');
|
||||||
|
END",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
conn.execute("INSERT INTO test VALUES (1, 'hello')")
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let mut stmt = conn.prepare("SELECT * FROM test ORDER BY rowid").unwrap();
|
||||||
|
let mut results = Vec::new();
|
||||||
|
loop {
|
||||||
|
match stmt.step().unwrap() {
|
||||||
|
turso_core::StepResult::Row => {
|
||||||
|
let row = stmt.row().unwrap();
|
||||||
|
results.push((
|
||||||
|
row.get_value(0).as_int().unwrap(),
|
||||||
|
row.get_value(1).cast_text().unwrap().to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
turso_core::StepResult::Done => break,
|
||||||
|
turso_core::StepResult::IO => {
|
||||||
|
stmt.run_once().unwrap();
|
||||||
|
}
|
||||||
|
_ => panic!("Unexpected step result"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Row inserted by trigger goes first
|
||||||
|
assert_eq!(results[0], (100, "triggered".to_string()));
|
||||||
|
assert_eq!(results[1], (1, "hello".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_drop_trigger() {
|
||||||
|
let db = TempDatabase::new_empty();
|
||||||
|
let conn = db.connect_limbo();
|
||||||
|
|
||||||
|
conn.execute("CREATE TABLE test (x INTEGER PRIMARY KEY)")
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
conn.execute("CREATE TRIGGER t1 BEFORE INSERT ON test BEGIN SELECT 1; END")
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Verify trigger exists
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare("SELECT name FROM sqlite_schema WHERE type='trigger' AND name='t1'")
|
||||||
|
.unwrap();
|
||||||
|
let mut results = Vec::new();
|
||||||
|
loop {
|
||||||
|
match stmt.step().unwrap() {
|
||||||
|
turso_core::StepResult::Row => {
|
||||||
|
let row = stmt.row().unwrap();
|
||||||
|
results.push(row.get_value(0).cast_text().unwrap().to_string());
|
||||||
|
}
|
||||||
|
turso_core::StepResult::Done => break,
|
||||||
|
turso_core::StepResult::IO => {
|
||||||
|
stmt.run_once().unwrap();
|
||||||
|
}
|
||||||
|
_ => panic!("Unexpected step result"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(results.len(), 1);
|
||||||
|
|
||||||
|
conn.execute("DROP TRIGGER t1").unwrap();
|
||||||
|
|
||||||
|
// Verify trigger is gone
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare("SELECT name FROM sqlite_schema WHERE type='trigger' AND name='t1'")
|
||||||
|
.unwrap();
|
||||||
|
let mut results = Vec::new();
|
||||||
|
loop {
|
||||||
|
match stmt.step().unwrap() {
|
||||||
|
turso_core::StepResult::Row => {
|
||||||
|
let row = stmt.row().unwrap();
|
||||||
|
results.push(row.get_value(0).cast_text().unwrap().to_string());
|
||||||
|
}
|
||||||
|
turso_core::StepResult::Done => break,
|
||||||
|
turso_core::StepResult::IO => {
|
||||||
|
stmt.run_once().unwrap();
|
||||||
|
}
|
||||||
|
_ => panic!("Unexpected step result"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(results.len(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_trigger_after_insert() {
|
||||||
|
let db = TempDatabase::new_empty();
|
||||||
|
let conn = db.connect_limbo();
|
||||||
|
|
||||||
|
conn.execute("CREATE TABLE test (x INTEGER PRIMARY KEY, y TEXT)")
|
||||||
|
.unwrap();
|
||||||
|
conn.execute("CREATE TABLE log (x INTEGER, y TEXT)")
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TRIGGER t1 AFTER INSERT ON test BEGIN
|
||||||
|
INSERT INTO log VALUES (NEW.x, NEW.y);
|
||||||
|
END",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
conn.execute("INSERT INTO test VALUES (1, 'hello')")
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let mut stmt = conn.prepare("SELECT * FROM log").unwrap();
|
||||||
|
let mut results = Vec::new();
|
||||||
|
loop {
|
||||||
|
match stmt.step().unwrap() {
|
||||||
|
turso_core::StepResult::Row => {
|
||||||
|
let row = stmt.row().unwrap();
|
||||||
|
results.push((
|
||||||
|
row.get_value(0).as_int().unwrap(),
|
||||||
|
row.get_value(1).cast_text().unwrap().to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
turso_core::StepResult::Done => break,
|
||||||
|
turso_core::StepResult::IO => {
|
||||||
|
stmt.run_once().unwrap();
|
||||||
|
}
|
||||||
|
_ => panic!("Unexpected step result"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(results.len(), 1);
|
||||||
|
assert_eq!(results[0], (1, "hello".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_trigger_when_clause() {
|
||||||
|
let db = TempDatabase::new_empty();
|
||||||
|
let conn = db.connect_limbo();
|
||||||
|
|
||||||
|
conn.execute("CREATE TABLE test (x INTEGER PRIMARY KEY, y INTEGER)")
|
||||||
|
.unwrap();
|
||||||
|
conn.execute("CREATE TABLE log (x INTEGER)").unwrap();
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TRIGGER t1 AFTER INSERT ON test WHEN NEW.y > 10 BEGIN
|
||||||
|
INSERT INTO log VALUES (NEW.x);
|
||||||
|
END",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
conn.execute("INSERT INTO test VALUES (1, 5)").unwrap();
|
||||||
|
conn.execute("INSERT INTO test VALUES (2, 15)").unwrap();
|
||||||
|
|
||||||
|
let mut stmt = conn.prepare("SELECT * FROM log").unwrap();
|
||||||
|
let mut results = Vec::new();
|
||||||
|
loop {
|
||||||
|
match stmt.step().unwrap() {
|
||||||
|
turso_core::StepResult::Row => {
|
||||||
|
let row = stmt.row().unwrap();
|
||||||
|
results.push(row.get_value(0).as_int().unwrap());
|
||||||
|
}
|
||||||
|
turso_core::StepResult::Done => break,
|
||||||
|
turso_core::StepResult::IO => {
|
||||||
|
stmt.run_once().unwrap();
|
||||||
|
}
|
||||||
|
_ => panic!("Unexpected step result"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(results.len(), 1);
|
||||||
|
assert_eq!(results[0], 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_trigger_drop_table_drops_triggers() {
|
||||||
|
let db = TempDatabase::new_empty();
|
||||||
|
let conn = db.connect_limbo();
|
||||||
|
|
||||||
|
conn.execute("CREATE TABLE test (x INTEGER PRIMARY KEY)")
|
||||||
|
.unwrap();
|
||||||
|
conn.execute("CREATE TRIGGER t1 BEFORE INSERT ON test BEGIN SELECT 1; END")
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Verify trigger exists
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare("SELECT name FROM sqlite_schema WHERE type='trigger' AND name='t1'")
|
||||||
|
.unwrap();
|
||||||
|
let mut results = Vec::new();
|
||||||
|
loop {
|
||||||
|
match stmt.step().unwrap() {
|
||||||
|
turso_core::StepResult::Row => {
|
||||||
|
let row = stmt.row().unwrap();
|
||||||
|
results.push(row.get_value(0).cast_text().unwrap().to_string());
|
||||||
|
}
|
||||||
|
turso_core::StepResult::Done => break,
|
||||||
|
turso_core::StepResult::IO => {
|
||||||
|
stmt.run_once().unwrap();
|
||||||
|
}
|
||||||
|
_ => panic!("Unexpected step result"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(results.len(), 1);
|
||||||
|
|
||||||
|
conn.execute("DROP TABLE test").unwrap();
|
||||||
|
|
||||||
|
// Verify trigger is gone
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare("SELECT name FROM sqlite_schema WHERE type='trigger' AND name='t1'")
|
||||||
|
.unwrap();
|
||||||
|
let mut results = Vec::new();
|
||||||
|
loop {
|
||||||
|
match stmt.step().unwrap() {
|
||||||
|
turso_core::StepResult::Row => {
|
||||||
|
let row = stmt.row().unwrap();
|
||||||
|
results.push(row.get_value(0).cast_text().unwrap().to_string());
|
||||||
|
}
|
||||||
|
turso_core::StepResult::Done => break,
|
||||||
|
turso_core::StepResult::IO => {
|
||||||
|
stmt.run_once().unwrap();
|
||||||
|
}
|
||||||
|
_ => panic!("Unexpected step result"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(results.len(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_trigger_new_old_references() {
|
||||||
|
let db = TempDatabase::new_empty();
|
||||||
|
let conn = db.connect_limbo();
|
||||||
|
|
||||||
|
conn.execute("CREATE TABLE test (x INTEGER PRIMARY KEY, y TEXT)")
|
||||||
|
.unwrap();
|
||||||
|
conn.execute("CREATE TABLE log (msg TEXT)").unwrap();
|
||||||
|
|
||||||
|
conn.execute("INSERT INTO test VALUES (1, 'hello')")
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TRIGGER t1 AFTER UPDATE ON test BEGIN
|
||||||
|
INSERT INTO log VALUES ('old=' || OLD.y || ' new=' || NEW.y);
|
||||||
|
END",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
conn.execute("UPDATE test SET y = 'world' WHERE x = 1")
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let mut stmt = conn.prepare("SELECT * FROM log").unwrap();
|
||||||
|
let mut results = Vec::new();
|
||||||
|
loop {
|
||||||
|
match stmt.step().unwrap() {
|
||||||
|
turso_core::StepResult::Row => {
|
||||||
|
let row = stmt.row().unwrap();
|
||||||
|
results.push(row.get_value(0).cast_text().unwrap().to_string());
|
||||||
|
}
|
||||||
|
turso_core::StepResult::Done => break,
|
||||||
|
turso_core::StepResult::IO => {
|
||||||
|
stmt.run_once().unwrap();
|
||||||
|
}
|
||||||
|
_ => panic!("Unexpected step result"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(results.len(), 1);
|
||||||
|
assert_eq!(results[0], "old=hello new=world");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_multiple_triggers_same_event() {
|
||||||
|
let db = TempDatabase::new_empty();
|
||||||
|
let conn = db.connect_limbo();
|
||||||
|
|
||||||
|
conn.execute("CREATE TABLE test (x INTEGER PRIMARY KEY)")
|
||||||
|
.unwrap();
|
||||||
|
conn.execute("CREATE TABLE log (msg TEXT)").unwrap();
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TRIGGER t1 BEFORE INSERT ON test BEGIN
|
||||||
|
INSERT INTO log VALUES ('trigger1');
|
||||||
|
END",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TRIGGER t2 BEFORE INSERT ON test BEGIN
|
||||||
|
INSERT INTO log VALUES ('trigger2');
|
||||||
|
END",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
conn.execute("INSERT INTO test VALUES (1)").unwrap();
|
||||||
|
|
||||||
|
let mut stmt = conn.prepare("SELECT * FROM log ORDER BY msg").unwrap();
|
||||||
|
let mut results = Vec::new();
|
||||||
|
loop {
|
||||||
|
match stmt.step().unwrap() {
|
||||||
|
turso_core::StepResult::Row => {
|
||||||
|
let row = stmt.row().unwrap();
|
||||||
|
results.push(row.get_value(0).cast_text().unwrap().to_string());
|
||||||
|
}
|
||||||
|
turso_core::StepResult::Done => break,
|
||||||
|
turso_core::StepResult::IO => {
|
||||||
|
stmt.run_once().unwrap();
|
||||||
|
}
|
||||||
|
_ => panic!("Unexpected step result"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(results.len(), 2);
|
||||||
|
assert_eq!(results[0], "trigger1");
|
||||||
|
assert_eq!(results[1], "trigger2");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_two_triggers_on_same_table() {
|
||||||
|
let db = TempDatabase::new_empty();
|
||||||
|
let conn = db.connect_limbo();
|
||||||
|
|
||||||
|
conn.execute("CREATE TABLE test (x, msg TEXT)").unwrap();
|
||||||
|
conn.execute("CREATE TABLE log (msg TEXT)").unwrap();
|
||||||
|
|
||||||
|
// Trigger A: fires on INSERT to test, inserts into log and test (which would trigger B)
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TRIGGER trigger_a AFTER INSERT ON test BEGIN
|
||||||
|
INSERT INTO log VALUES ('trigger_a fired for x=' || NEW.x);
|
||||||
|
INSERT INTO test VALUES (NEW.x + 100, 'from_a');
|
||||||
|
END",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Trigger B: fires on INSERT to test, inserts into log and test (which would trigger A)
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TRIGGER trigger_b AFTER INSERT ON test BEGIN
|
||||||
|
INSERT INTO log VALUES ('trigger_b fired for x=' || NEW.x);
|
||||||
|
INSERT INTO test VALUES (NEW.x + 200, 'from_b');
|
||||||
|
END",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Insert initial row - this should trigger A, which triggers B, which tries to trigger A again (prevented)
|
||||||
|
conn.execute("INSERT INTO test VALUES (1, 'initial')")
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Check log entries to verify recursion was prevented
|
||||||
|
let mut stmt = conn.prepare("SELECT * FROM log ORDER BY rowid").unwrap();
|
||||||
|
let mut results = Vec::new();
|
||||||
|
loop {
|
||||||
|
match stmt.step().unwrap() {
|
||||||
|
turso_core::StepResult::Row => {
|
||||||
|
let row = stmt.row().unwrap();
|
||||||
|
results.push(row.get_value(0).cast_text().unwrap().to_string());
|
||||||
|
}
|
||||||
|
turso_core::StepResult::Done => break,
|
||||||
|
turso_core::StepResult::IO => {
|
||||||
|
stmt.run_once().unwrap();
|
||||||
|
}
|
||||||
|
_ => panic!("Unexpected step result"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// At minimum, we should see both triggers fire and not infinite loop
|
||||||
|
assert!(
|
||||||
|
results.len() >= 2,
|
||||||
|
"Expected at least 2 log entries, got {}",
|
||||||
|
results.len()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
results.iter().any(|s| s.contains("trigger_a")),
|
||||||
|
"trigger_a should have fired"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
results.iter().any(|s| s.contains("trigger_b")),
|
||||||
|
"trigger_b should have fired"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_trigger_mutual_recursion() {
|
||||||
|
let db = TempDatabase::new_empty();
|
||||||
|
let conn = db.connect_limbo();
|
||||||
|
|
||||||
|
conn.execute("CREATE TABLE t (id INTEGER, msg TEXT)")
|
||||||
|
.unwrap();
|
||||||
|
conn.execute("CREATE TABLE u (id INTEGER, msg TEXT)")
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Trigger on T: fires on INSERT to t, inserts into u
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TRIGGER trigger_on_t AFTER INSERT ON t BEGIN
|
||||||
|
INSERT INTO u VALUES (NEW.id + 1000, 'from_t');
|
||||||
|
END",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Trigger on U: fires on INSERT to u, inserts into t
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TRIGGER trigger_on_u AFTER INSERT ON u BEGIN
|
||||||
|
INSERT INTO t VALUES (NEW.id + 2000, 'from_u');
|
||||||
|
END",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Insert initial row into t - this should trigger the chain
|
||||||
|
conn.execute("INSERT INTO t VALUES (1, 'initial')").unwrap();
|
||||||
|
|
||||||
|
// Check that both tables have entries
|
||||||
|
let mut stmt = conn.prepare("SELECT * FROM t ORDER BY rowid").unwrap();
|
||||||
|
let mut t_results = Vec::new();
|
||||||
|
loop {
|
||||||
|
match stmt.step().unwrap() {
|
||||||
|
turso_core::StepResult::Row => {
|
||||||
|
let row = stmt.row().unwrap();
|
||||||
|
t_results.push((
|
||||||
|
row.get_value(0).as_int().unwrap(),
|
||||||
|
row.get_value(1).cast_text().unwrap().to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
turso_core::StepResult::Done => break,
|
||||||
|
turso_core::StepResult::IO => {
|
||||||
|
stmt.run_once().unwrap();
|
||||||
|
}
|
||||||
|
_ => panic!("Unexpected step result"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut stmt = conn.prepare("SELECT * FROM u ORDER BY rowid").unwrap();
|
||||||
|
let mut u_results = Vec::new();
|
||||||
|
loop {
|
||||||
|
match stmt.step().unwrap() {
|
||||||
|
turso_core::StepResult::Row => {
|
||||||
|
let row = stmt.row().unwrap();
|
||||||
|
u_results.push((
|
||||||
|
row.get_value(0).as_int().unwrap(),
|
||||||
|
row.get_value(1).cast_text().unwrap().to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
turso_core::StepResult::Done => break,
|
||||||
|
turso_core::StepResult::IO => {
|
||||||
|
stmt.run_once().unwrap();
|
||||||
|
}
|
||||||
|
_ => panic!("Unexpected step result"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the chain executed without infinite recursion
|
||||||
|
assert!(!t_results.is_empty(), "Expected at least 1 entry in t");
|
||||||
|
assert!(!u_results.is_empty(), "Expected at least 1 entry in u");
|
||||||
|
|
||||||
|
// Verify initial insert
|
||||||
|
assert_eq!(t_results[0], (1, "initial".to_string()));
|
||||||
|
|
||||||
|
// Verify trigger on t fired (inserted into u)
|
||||||
|
assert_eq!(u_results[0], (1001, "from_t".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_after_insert_trigger() {
|
||||||
|
let db = TempDatabase::new_empty();
|
||||||
|
let conn = db.connect_limbo();
|
||||||
|
|
||||||
|
// Create table and log table
|
||||||
|
conn.execute("CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)")
|
||||||
|
.unwrap();
|
||||||
|
conn.execute("CREATE TABLE audit_log (action TEXT, item_id INTEGER, item_name TEXT)")
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Create AFTER INSERT trigger
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TRIGGER after_insert_items
|
||||||
|
AFTER INSERT ON items
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO audit_log VALUES ('INSERT', NEW.id, NEW.name);
|
||||||
|
END",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Insert data
|
||||||
|
conn.execute("INSERT INTO items VALUES (1, 'apple')")
|
||||||
|
.unwrap();
|
||||||
|
conn.execute("INSERT INTO items VALUES (2, 'banana')")
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Verify audit log
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare("SELECT * FROM audit_log ORDER BY rowid")
|
||||||
|
.unwrap();
|
||||||
|
let mut results = Vec::new();
|
||||||
|
loop {
|
||||||
|
match stmt.step().unwrap() {
|
||||||
|
turso_core::StepResult::Row => {
|
||||||
|
let row = stmt.row().unwrap();
|
||||||
|
results.push((
|
||||||
|
row.get_value(0).cast_text().unwrap().to_string(),
|
||||||
|
row.get_value(1).as_int().unwrap(),
|
||||||
|
row.get_value(2).cast_text().unwrap().to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
turso_core::StepResult::Done => break,
|
||||||
|
turso_core::StepResult::IO => {
|
||||||
|
stmt.run_once().unwrap();
|
||||||
|
}
|
||||||
|
_ => panic!("Unexpected step result"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(results.len(), 2);
|
||||||
|
assert_eq!(results[0], ("INSERT".to_string(), 1, "apple".to_string()));
|
||||||
|
assert_eq!(results[1], ("INSERT".to_string(), 2, "banana".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_before_update_of_trigger() {
|
||||||
|
let db = TempDatabase::new_empty();
|
||||||
|
let conn = db.connect_limbo();
|
||||||
|
|
||||||
|
// Create table with multiple columns
|
||||||
|
conn.execute("CREATE TABLE products (id INTEGER PRIMARY KEY, name TEXT, price INTEGER)")
|
||||||
|
.unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TABLE price_history (product_id INTEGER, old_price INTEGER, new_price INTEGER)",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Create BEFORE UPDATE OF trigger - only fires when price column is updated
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TRIGGER before_update_price
|
||||||
|
BEFORE UPDATE OF price ON products
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO price_history VALUES (OLD.id, OLD.price, NEW.price);
|
||||||
|
END",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Insert initial data
|
||||||
|
conn.execute("INSERT INTO products VALUES (1, 'widget', 100)")
|
||||||
|
.unwrap();
|
||||||
|
conn.execute("INSERT INTO products VALUES (2, 'gadget', 200)")
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Update price - should fire trigger
|
||||||
|
conn.execute("UPDATE products SET price = 150 WHERE id = 1")
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Update name only - should NOT fire trigger
|
||||||
|
conn.execute("UPDATE products SET name = 'super widget' WHERE id = 1")
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Update both name and price - should fire trigger
|
||||||
|
conn.execute("UPDATE products SET name = 'mega gadget', price = 250 WHERE id = 2")
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Verify price history
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare("SELECT * FROM price_history ORDER BY rowid")
|
||||||
|
.unwrap();
|
||||||
|
let mut results = Vec::new();
|
||||||
|
loop {
|
||||||
|
match stmt.step().unwrap() {
|
||||||
|
turso_core::StepResult::Row => {
|
||||||
|
let row = stmt.row().unwrap();
|
||||||
|
results.push((
|
||||||
|
row.get_value(0).as_int().unwrap(),
|
||||||
|
row.get_value(1).as_int().unwrap(),
|
||||||
|
row.get_value(2).as_int().unwrap(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
turso_core::StepResult::Done => break,
|
||||||
|
turso_core::StepResult::IO => {
|
||||||
|
stmt.run_once().unwrap();
|
||||||
|
}
|
||||||
|
_ => panic!("Unexpected step result"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should have 2 entries (not 3, because name-only update didn't fire)
|
||||||
|
assert_eq!(results.len(), 2);
|
||||||
|
assert_eq!(results[0], (1, 100, 150));
|
||||||
|
assert_eq!(results[1], (2, 200, 250));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_after_update_of_trigger() {
|
||||||
|
let db = TempDatabase::new_empty();
|
||||||
|
let conn = db.connect_limbo();
|
||||||
|
|
||||||
|
// Create table
|
||||||
|
conn.execute("CREATE TABLE employees (id INTEGER PRIMARY KEY, name TEXT, salary INTEGER)")
|
||||||
|
.unwrap();
|
||||||
|
conn.execute("CREATE TABLE salary_changes (emp_id INTEGER, old_salary INTEGER, new_salary INTEGER, change_amount INTEGER)")
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Create AFTER UPDATE OF trigger with multiple statements
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TRIGGER after_update_salary
|
||||||
|
AFTER UPDATE OF salary ON employees
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO salary_changes VALUES (NEW.id, OLD.salary, NEW.salary, NEW.salary - OLD.salary);
|
||||||
|
END",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Insert initial data
|
||||||
|
conn.execute("INSERT INTO employees VALUES (1, 'Alice', 50000)")
|
||||||
|
.unwrap();
|
||||||
|
conn.execute("INSERT INTO employees VALUES (2, 'Bob', 60000)")
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Update salary
|
||||||
|
conn.execute("UPDATE employees SET salary = 55000 WHERE id = 1")
|
||||||
|
.unwrap();
|
||||||
|
conn.execute("UPDATE employees SET salary = 65000 WHERE id = 2")
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Verify salary changes
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare("SELECT * FROM salary_changes ORDER BY rowid")
|
||||||
|
.unwrap();
|
||||||
|
let mut results = Vec::new();
|
||||||
|
loop {
|
||||||
|
match stmt.step().unwrap() {
|
||||||
|
turso_core::StepResult::Row => {
|
||||||
|
let row = stmt.row().unwrap();
|
||||||
|
results.push((
|
||||||
|
row.get_value(0).as_int().unwrap(),
|
||||||
|
row.get_value(1).as_int().unwrap(),
|
||||||
|
row.get_value(2).as_int().unwrap(),
|
||||||
|
row.get_value(3).as_int().unwrap(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
turso_core::StepResult::Done => break,
|
||||||
|
turso_core::StepResult::IO => {
|
||||||
|
stmt.run_once().unwrap();
|
||||||
|
}
|
||||||
|
_ => panic!("Unexpected step result"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(results.len(), 2);
|
||||||
|
assert_eq!(results[0], (1, 50000, 55000, 5000));
|
||||||
|
assert_eq!(results[1], (2, 60000, 65000, 5000));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn log(s: &str) -> &str {
|
||||||
|
tracing::info!("{}", s);
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_before_delete_trigger() {
|
||||||
|
let db = TempDatabase::new_empty();
|
||||||
|
let conn = db.connect_limbo();
|
||||||
|
|
||||||
|
// Create tables
|
||||||
|
conn.execute(log(
|
||||||
|
"CREATE TABLE users (id INTEGER PRIMARY KEY, username TEXT)",
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
conn.execute(log(
|
||||||
|
"CREATE TABLE deleted_users (id INTEGER, username TEXT, deleted_at INTEGER)",
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Create BEFORE DELETE trigger
|
||||||
|
conn.execute(log("CREATE TRIGGER before_delete_users
|
||||||
|
BEFORE DELETE ON users
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO deleted_users VALUES (OLD.id, OLD.username, 12345);
|
||||||
|
END"))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Insert data
|
||||||
|
conn.execute(log("INSERT INTO users VALUES (1, 'alice')"))
|
||||||
|
.unwrap();
|
||||||
|
conn.execute(log("INSERT INTO users VALUES (2, 'bob')"))
|
||||||
|
.unwrap();
|
||||||
|
conn.execute(log("INSERT INTO users VALUES (3, 'charlie')"))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Delete some users
|
||||||
|
conn.execute(log("DELETE FROM users WHERE id = 2")).unwrap();
|
||||||
|
conn.execute(log("DELETE FROM users WHERE id = 3")).unwrap();
|
||||||
|
|
||||||
|
// Verify deleted_users table
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare(log("SELECT * FROM deleted_users ORDER BY id"))
|
||||||
|
.unwrap();
|
||||||
|
let mut results = Vec::new();
|
||||||
|
loop {
|
||||||
|
match stmt.step().unwrap() {
|
||||||
|
turso_core::StepResult::Row => {
|
||||||
|
let row = stmt.row().unwrap();
|
||||||
|
results.push((
|
||||||
|
row.get_value(0).as_int().unwrap(),
|
||||||
|
row.get_value(1).cast_text().unwrap().to_string(),
|
||||||
|
row.get_value(2).as_int().unwrap(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
turso_core::StepResult::Done => break,
|
||||||
|
turso_core::StepResult::IO => {
|
||||||
|
stmt.run_once().unwrap();
|
||||||
|
}
|
||||||
|
_ => panic!("Unexpected step result"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(results.len(), 2);
|
||||||
|
assert_eq!(results[0], (2, "bob".to_string(), 12345));
|
||||||
|
assert_eq!(results[1], (3, "charlie".to_string(), 12345));
|
||||||
|
|
||||||
|
// Verify remaining users
|
||||||
|
let mut stmt = conn.prepare(log("SELECT COUNT(*) FROM users")).unwrap();
|
||||||
|
loop {
|
||||||
|
match stmt.step().unwrap() {
|
||||||
|
turso_core::StepResult::Row => {
|
||||||
|
let row = stmt.row().unwrap();
|
||||||
|
assert_eq!(row.get_value(0).as_int().unwrap(), 1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
turso_core::StepResult::Done => break,
|
||||||
|
turso_core::StepResult::IO => {
|
||||||
|
stmt.run_once().unwrap();
|
||||||
|
}
|
||||||
|
_ => panic!("Unexpected step result"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_after_delete_trigger() {
|
||||||
|
let db = TempDatabase::new_empty();
|
||||||
|
let conn = db.connect_limbo();
|
||||||
|
|
||||||
|
// Create tables
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TABLE orders (id INTEGER PRIMARY KEY, customer_id INTEGER, amount INTEGER)",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TABLE order_archive (order_id INTEGER, customer_id INTEGER, amount INTEGER)",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Create AFTER DELETE trigger
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TRIGGER after_delete_orders
|
||||||
|
AFTER DELETE ON orders
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO order_archive VALUES (OLD.id, OLD.customer_id, OLD.amount);
|
||||||
|
END",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Insert data
|
||||||
|
conn.execute("INSERT INTO orders VALUES (1, 100, 50)")
|
||||||
|
.unwrap();
|
||||||
|
conn.execute("INSERT INTO orders VALUES (2, 101, 75)")
|
||||||
|
.unwrap();
|
||||||
|
conn.execute("INSERT INTO orders VALUES (3, 100, 100)")
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Delete orders
|
||||||
|
conn.execute("DELETE FROM orders WHERE customer_id = 100")
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Verify archive
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare("SELECT * FROM order_archive ORDER BY order_id")
|
||||||
|
.unwrap();
|
||||||
|
let mut results = Vec::new();
|
||||||
|
loop {
|
||||||
|
match stmt.step().unwrap() {
|
||||||
|
turso_core::StepResult::Row => {
|
||||||
|
let row = stmt.row().unwrap();
|
||||||
|
results.push((
|
||||||
|
row.get_value(0).as_int().unwrap(),
|
||||||
|
row.get_value(1).as_int().unwrap(),
|
||||||
|
row.get_value(2).as_int().unwrap(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
turso_core::StepResult::Done => break,
|
||||||
|
turso_core::StepResult::IO => {
|
||||||
|
stmt.run_once().unwrap();
|
||||||
|
}
|
||||||
|
_ => panic!("Unexpected step result"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(results.len(), 2);
|
||||||
|
assert_eq!(results[0], (1, 100, 50));
|
||||||
|
assert_eq!(results[1], (3, 100, 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_trigger_with_multiple_statements() {
|
||||||
|
let db = TempDatabase::new_empty();
|
||||||
|
let conn = db.connect_limbo();
|
||||||
|
|
||||||
|
// Create tables
|
||||||
|
conn.execute("CREATE TABLE accounts (id INTEGER PRIMARY KEY, balance INTEGER)")
|
||||||
|
.unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TABLE transactions (account_id INTEGER, old_balance INTEGER, new_balance INTEGER)",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
conn.execute("CREATE TABLE audit (message TEXT)").unwrap();
|
||||||
|
|
||||||
|
// Create trigger with multiple statements
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TRIGGER track_balance_changes
|
||||||
|
AFTER UPDATE OF balance ON accounts
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO transactions VALUES (NEW.id, OLD.balance, NEW.balance);
|
||||||
|
INSERT INTO audit VALUES ('Balance changed for account ' || NEW.id);
|
||||||
|
END",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Insert initial data
|
||||||
|
conn.execute("INSERT INTO accounts VALUES (1, 1000)")
|
||||||
|
.unwrap();
|
||||||
|
conn.execute("INSERT INTO accounts VALUES (2, 2000)")
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Update balances
|
||||||
|
conn.execute("UPDATE accounts SET balance = 1500 WHERE id = 1")
|
||||||
|
.unwrap();
|
||||||
|
conn.execute("UPDATE accounts SET balance = 2500 WHERE id = 2")
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Verify transactions table
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare("SELECT * FROM transactions ORDER BY rowid")
|
||||||
|
.unwrap();
|
||||||
|
let mut trans_results = Vec::new();
|
||||||
|
loop {
|
||||||
|
match stmt.step().unwrap() {
|
||||||
|
turso_core::StepResult::Row => {
|
||||||
|
let row = stmt.row().unwrap();
|
||||||
|
trans_results.push((
|
||||||
|
row.get_value(0).as_int().unwrap(),
|
||||||
|
row.get_value(1).as_int().unwrap(),
|
||||||
|
row.get_value(2).as_int().unwrap(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
turso_core::StepResult::Done => break,
|
||||||
|
turso_core::StepResult::IO => {
|
||||||
|
stmt.run_once().unwrap();
|
||||||
|
}
|
||||||
|
_ => panic!("Unexpected step result"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(trans_results.len(), 2);
|
||||||
|
assert_eq!(trans_results[0], (1, 1000, 1500));
|
||||||
|
assert_eq!(trans_results[1], (2, 2000, 2500));
|
||||||
|
|
||||||
|
// Verify audit table
|
||||||
|
let mut stmt = conn.prepare("SELECT * FROM audit ORDER BY rowid").unwrap();
|
||||||
|
let mut audit_results = Vec::new();
|
||||||
|
loop {
|
||||||
|
match stmt.step().unwrap() {
|
||||||
|
turso_core::StepResult::Row => {
|
||||||
|
let row = stmt.row().unwrap();
|
||||||
|
audit_results.push(row.get_value(0).cast_text().unwrap().to_string());
|
||||||
|
}
|
||||||
|
turso_core::StepResult::Done => break,
|
||||||
|
turso_core::StepResult::IO => {
|
||||||
|
stmt.run_once().unwrap();
|
||||||
|
}
|
||||||
|
_ => panic!("Unexpected step result"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(audit_results.len(), 2);
|
||||||
|
assert_eq!(audit_results[0], "Balance changed for account 1");
|
||||||
|
assert_eq!(audit_results[1], "Balance changed for account 2");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user