From a232e3cc7aa58f23648c2fb81010ff2253caa85e Mon Sep 17 00:00:00 2001 From: PThorpe92 Date: Wed, 1 Oct 2025 13:54:13 -0400 Subject: [PATCH] Implement proper handling of deferred foreign keys --- core/lib.rs | 3 +- core/schema.rs | 272 ++++---- core/translate/collate.rs | 4 + core/translate/emitter.rs | 1171 +++++++-------------------------- core/translate/fkeys.rs | 1025 +++++++++++++++++++++++++++++ core/translate/insert.rs | 469 ++++++++----- core/translate/mod.rs | 1 + core/translate/upsert.rs | 187 +----- core/vdbe/builder.rs | 3 + core/vdbe/execute.rs | 118 ++-- core/vdbe/explain.rs | 8 +- core/vdbe/insn.rs | 3 +- core/vdbe/mod.rs | 4 +- testing/foreign_keys.test | 717 +++++++++++++++++++- tests/integration/fuzz/mod.rs | 695 ++++++++++++++++++- 15 files changed, 3262 insertions(+), 1418 deletions(-) create mode 100644 core/translate/fkeys.rs diff --git a/core/lib.rs b/core/lib.rs index 43e07b609..a2ac4a267 100644 --- a/core/lib.rs +++ b/core/lib.rs @@ -583,6 +583,7 @@ impl Database { busy_timeout: RwLock::new(Duration::new(0, 0)), is_mvcc_bootstrap_connection: AtomicBool::new(is_mvcc_bootstrap_connection), fk_pragma: AtomicBool::new(false), + fk_deferred_violations: AtomicIsize::new(0), }); self.n_connections .fetch_add(1, std::sync::atomic::Ordering::SeqCst); @@ -1102,6 +1103,7 @@ pub struct Connection { is_mvcc_bootstrap_connection: AtomicBool, /// Whether pragma foreign_keys=ON for this connection fk_pragma: AtomicBool, + fk_deferred_violations: AtomicIsize, } impl Drop for Connection { @@ -1540,7 +1542,6 @@ impl Connection { pub fn foreign_keys_enabled(&self) -> bool { self.fk_pragma.load(Ordering::Acquire) } - pub(crate) fn clear_deferred_foreign_key_violations(&self) -> isize { self.fk_deferred_violations.swap(0, Ordering::Release) } diff --git a/core/schema.rs b/core/schema.rs index 29a805294..f81c8fe2e 100644 --- a/core/schema.rs +++ b/core/schema.rs @@ -849,12 +849,17 @@ impl Schema { /// Compute all resolved FKs *referencing* `table_name` (arg: `table_name` is the parent). /// Each item contains the child table, normalized columns/positions, and the parent lookup /// strategy (rowid vs. UNIQUE index or PK). - pub fn resolved_fks_referencing(&self, table_name: &str) -> Vec { + pub fn resolved_fks_referencing(&self, table_name: &str) -> Result> { + let fk_mismatch_err = |child: &str, parent: &str| -> crate::LimboError { + crate::LimboError::Constraint(format!( + "foreign key mismatch - \"{child}\" referencing \"{parent}\"" + )) + }; let target = normalize_ident(table_name); let mut out = Vec::with_capacity(4); // arbitrary estimate let parent_tbl = self .get_btree_table(&target) - .expect("parent table must exist"); + .ok_or_else(|| fk_mismatch_err("", &target))?; // Precompute helper to find parent unique index, if it's not the rowid let find_parent_unique = |cols: &Vec| -> Option> { @@ -875,78 +880,82 @@ impl Schema { let Some(child) = t.btree() else { continue; }; - for fk in &child.foreign_keys { - if fk.parent_table != target { + if !fk.parent_table.eq_ignore_ascii_case(&target) { continue; } - - // Resolve + normalize columns + if fk.child_columns.is_empty() { + // SQLite requires an explicit child column list unless the table has a single-column PK that + return Err(fk_mismatch_err(&child.name, &parent_tbl.name)); + } let child_cols: Vec = fk.child_columns.clone(); + let mut child_pos = Vec::with_capacity(child_cols.len()); - // If no explicit parent columns were given, they were validated in add_btree_table() - // to match the parent's PK. We resolve them the same way here. + for cname in &child_cols { + let (i, _) = child + .get_column(cname) + .ok_or_else(|| fk_mismatch_err(&child.name, &parent_tbl.name))?; + child_pos.push(i); + } let parent_cols: Vec = if fk.parent_columns.is_empty() { - parent_tbl - .primary_key_columns - .iter() - .map(|(col, _)| col) - .cloned() - .collect() + if !parent_tbl.primary_key_columns.is_empty() { + parent_tbl + .primary_key_columns + .iter() + .map(|(col, _)| col) + .cloned() + .collect() + } else { + return Err(fk_mismatch_err(&child.name, &parent_tbl.name)); + } } else { fk.parent_columns.clone() }; - // Child positions - let child_pos: Vec = child_cols - .iter() - .map(|cname| { - child - .get_column(cname) - .map(|(i, _)| i) - .unwrap_or_else(|| panic!("child col {}.{} missing", child.name, cname)) - }) - .collect(); + // Same length required + if parent_cols.len() != child_cols.len() { + return Err(fk_mismatch_err(&child.name, &parent_tbl.name)); + } - let parent_pos: Vec = parent_cols - .iter() - .map(|cname| { - // Allow "rowid" sentinel; return 0 but it won't be used when parent_uses_rowid == true - parent_tbl - .get_column(cname) - .map(|(i, _)| i) - .or_else(|| { - if cname.eq_ignore_ascii_case("rowid") { - Some(0) - } else { - None - } - }) - .unwrap_or_else(|| { - panic!("parent col {}.{cname} missing", parent_tbl.name) - }) - }) - .collect(); + let mut parent_pos = Vec::with_capacity(parent_cols.len()); + for pc in &parent_cols { + let pos = parent_tbl.get_column(pc).map(|(i, _)| i).or_else(|| { + ROWID_STRS + .iter() + .any(|s| pc.eq_ignore_ascii_case(s)) + .then_some(0) + }); + let Some(p) = pos else { + return Err(fk_mismatch_err(&child.name, &parent_tbl.name)); + }; + parent_pos.push(p); + } - // Detect parent rowid usage (single-column and rowid/alias) - let parent_uses_rowid = parent_cols.len() == 1 && { - let c = parent_cols[0].as_str(); - c.eq_ignore_ascii_case("rowid") - || parent_tbl.columns.iter().any(|col| { - col.is_rowid_alias - && col - .name + // Determine if parent key is ROWID/alias + let parent_uses_rowid = parent_tbl.primary_key_columns.len().eq(&1) && { + if parent_tbl.primary_key_columns.len() == 1 { + let pk_name = &parent_tbl.primary_key_columns[0].0; + // rowid or alias INTEGER PRIMARY KEY; either is ok implicitly + parent_tbl.columns.iter().any(|c| { + c.is_rowid_alias + && c.name .as_deref() - .is_some_and(|n| n.eq_ignore_ascii_case(c)) - }) + .is_some_and(|n| n.eq_ignore_ascii_case(pk_name)) + }) || ROWID_STRS.iter().any(|&r| r.eq_ignore_ascii_case(pk_name)) + } else { + false + } }; + // If not rowid, there must be a non-partial UNIQUE exactly on parent_cols let parent_unique_index = if parent_uses_rowid { None } else { find_parent_unique(&parent_cols) + .ok_or_else(|| fk_mismatch_err(&child.name, &parent_tbl.name))? + .into() }; - + fk.validate()?; out.push(ResolvedFkRef { child_table: Arc::clone(&child), fk: Arc::clone(fk), @@ -959,80 +968,80 @@ impl Schema { }); } } - out + Ok(out) } /// Compute all resolved FKs *declared by* `child_table` - pub fn resolved_fks_for_child(&self, child_table: &str) -> Vec { - let child_name = normalize_ident(child_table); - let Some(child) = self.get_btree_table(&child_name) else { - return vec![]; + pub fn resolved_fks_for_child(&self, child_table: &str) -> crate::Result> { + let fk_mismatch_err = |child: &str, parent: &str| -> crate::LimboError { + crate::LimboError::Constraint(format!( + "foreign key mismatch - \"{child}\" referencing \"{parent}\"" + )) }; - - // Helper to find the UNIQUE/index on the parent that matches the resolved parent cols - let find_parent_unique = - |parent_tbl: &BTreeTable, cols: &Vec| -> Option> { - self.get_indices(&parent_tbl.name) - .find(|idx| { - idx.unique - && idx.columns.len() == cols.len() - && idx - .columns - .iter() - .zip(cols.iter()) - .all(|(ic, pc)| ic.name.eq_ignore_ascii_case(pc)) - }) - .cloned() - }; + let child_name = normalize_ident(child_table); + let child = self + .get_btree_table(&child_name) + .ok_or_else(|| fk_mismatch_err(&child_name, ""))?; let mut out = Vec::with_capacity(child.foreign_keys.len()); - for fk in &child.foreign_keys { - let parent_name = &fk.parent_table; - let Some(parent_tbl) = self.get_btree_table(parent_name) else { - continue; - }; - // Normalize columns - let child_cols: Vec = fk - .child_columns - .iter() - .map(|s| normalize_ident(s)) - .collect(); + for fk in &child.foreign_keys { + let parent_name = normalize_ident(&fk.parent_table); + let parent_tbl = self + .get_btree_table(&parent_name) + .ok_or_else(|| fk_mismatch_err(&child.name, &parent_name))?; + + let child_cols: Vec = fk.child_columns.clone(); + if child_cols.is_empty() { + return Err(fk_mismatch_err(&child.name, &parent_tbl.name)); + } + + // Child positions exist + let mut child_pos = Vec::with_capacity(child_cols.len()); + for cname in &child_cols { + let (i, _) = child + .get_column(cname) + .ok_or_else(|| fk_mismatch_err(&child.name, &parent_tbl.name))?; + child_pos.push(i); + } + let parent_cols: Vec = if fk.parent_columns.is_empty() { if !parent_tbl.primary_key_columns.is_empty() { parent_tbl .primary_key_columns .iter() - .map(|(n, _)| normalize_ident(n)) + .map(|(col, _)| col) + .cloned() .collect() } else { - vec!["rowid".to_string()] + return Err(fk_mismatch_err(&child.name, &parent_tbl.name)); } } else { - fk.parent_columns - .iter() - .map(|s| normalize_ident(s)) - .collect() + fk.parent_columns.clone() }; - let child_pos: Vec = child_cols - .iter() - .map(|c| child.get_column(c).expect("child col missing").0) - .collect(); - let parent_pos: Vec = parent_cols - .iter() - .map(|c| { - parent_tbl - .get_column(c) - .map(|(i, _)| i) - .or_else(|| c.eq_ignore_ascii_case("rowid").then_some(0)) - .expect("parent col missing") - }) - .collect(); + if parent_cols.len() != child_cols.len() { + return Err(fk_mismatch_err(&child.name, &parent_tbl.name)); + } - let parent_uses_rowid = parent_cols.len() == 1 && { + // Parent positions exist, or rowid sentinel + let mut parent_pos = Vec::with_capacity(parent_cols.len()); + for pc in &parent_cols { + let pos = parent_tbl.get_column(pc).map(|(i, _)| i).or_else(|| { + ROWID_STRS + .iter() + .any(|&r| r.eq_ignore_ascii_case(pc)) + .then_some(0) + }); + let Some(p) = pos else { + return Err(fk_mismatch_err(&child.name, &parent_tbl.name)); + }; + parent_pos.push(p); + } + + let parent_uses_rowid = parent_cols.len().eq(&1) && { let c = parent_cols[0].as_str(); - c.eq_ignore_ascii_case("rowid") + ROWID_STRS.iter().any(|&r| r.eq_ignore_ascii_case(c)) || parent_tbl.columns.iter().any(|col| { col.is_rowid_alias && col @@ -1042,12 +1051,27 @@ impl Schema { }) }; + // Must be PK or a non-partial UNIQUE on exactly those columns. let parent_unique_index = if parent_uses_rowid { None } else { - find_parent_unique(&parent_tbl, &parent_cols) + self.get_indices(&parent_tbl.name) + .find(|idx| { + idx.unique + && idx.where_clause.is_none() + && idx.columns.len() == parent_cols.len() + && idx + .columns + .iter() + .zip(parent_cols.iter()) + .all(|(ic, pc)| ic.name.eq_ignore_ascii_case(pc)) + }) + .cloned() + .ok_or_else(|| fk_mismatch_err(&child.name, &parent_tbl.name))? + .into() }; + fk.validate()?; out.push(ResolvedFkRef { child_table: Arc::clone(&child), fk: Arc::clone(fk), @@ -1059,7 +1083,8 @@ impl Schema { parent_unique_index, }); } - out + + Ok(out) } /// Returns if any table declares a FOREIGN KEY whose parent is `table_name`. @@ -1534,7 +1559,7 @@ pub fn create_table(tbl_name: &str, body: &CreateTableBody, root_page: i64) -> R constraints, } in columns { - let name = normalize_ident(col_name.as_str()); + let name = col_name.as_str().to_string(); // Regular sqlite tables have an integer rowid that uniquely identifies a row. // Even if you create a table with a column e.g. 'id INT PRIMARY KEY', there will still // be a separate hidden rowid, and the 'id' column will have a separate index built for it. @@ -1673,7 +1698,7 @@ pub fn create_table(tbl_name: &str, body: &CreateTableBody, root_page: i64) -> R } cols.push(Column { - name: Some(name), + name: Some(normalize_ident(&name)), ty, ty_str, primary_key, @@ -1809,6 +1834,29 @@ pub struct ForeignKey { /// DEFERRABLE INITIALLY DEFERRED pub deferred: bool, } +impl ForeignKey { + fn validate(&self) -> Result<()> { + // TODO: remove this when actions are implemented + if !(matches!(self.on_update, RefAct::NoAction) + && matches!(self.on_delete, RefAct::NoAction)) + { + crate::bail_parse_error!( + "foreign key actions other than NO ACTION are not implemented" + ); + } + if self + .parent_columns + .iter() + .any(|c| ROWID_STRS.iter().any(|&r| r.eq_ignore_ascii_case(c))) + { + return Err(crate::LimboError::Constraint(format!( + "foreign key mismatch referencing \"{}\"", + self.parent_table + ))); + } + Ok(()) + } +} /// A single resolved foreign key where `parent_table == target`. #[derive(Clone, Debug)] diff --git a/core/translate/collate.rs b/core/translate/collate.rs index 04324424c..721d82944 100644 --- a/core/translate/collate.rs +++ b/core/translate/collate.rs @@ -371,6 +371,7 @@ mod tests { hidden: false, }], unique_sets: vec![], + foreign_keys: vec![], })), }); @@ -413,6 +414,7 @@ mod tests { hidden: false, }], unique_sets: vec![], + foreign_keys: vec![], })), }); // Right table t2(id=2) @@ -446,6 +448,7 @@ mod tests { hidden: false, }], unique_sets: vec![], + foreign_keys: vec![], })), }); table_references @@ -486,6 +489,7 @@ mod tests { hidden: false, }], unique_sets: vec![], + foreign_keys: vec![], })), }); table_references diff --git a/core/translate/emitter.rs b/core/translate/emitter.rs index 21107843d..e281277dc 100644 --- a/core/translate/emitter.rs +++ b/core/translate/emitter.rs @@ -24,12 +24,17 @@ use super::select::emit_simple_count; use super::subquery::emit_subqueries; use crate::error::SQLITE_CONSTRAINT_PRIMARYKEY; use crate::function::Func; -use crate::schema::{BTreeTable, Column, ResolvedFkRef, Schema, Table, ROWID_SENTINEL}; +use crate::schema::{BTreeTable, Column, Schema, Table, ROWID_SENTINEL}; use crate::translate::compound_select::emit_program_for_compound_select; use crate::translate::expr::{ emit_returning_results, translate_expr_no_constant_opt, walk_expr_mut, NoConstantOptReason, ReturningValueRegisters, WalkControl, }; +use crate::translate::fkeys::{ + build_index_affinity_string, emit_fk_child_update_counters, + emit_fk_delete_parent_existence_checks, emit_fk_scope_if_needed, emit_parent_pk_change_checks, + stabilize_new_row_for_fk, +}; use crate::translate::plan::{DeletePlan, JoinedTable, Plan, QueryDestination, Search}; use crate::translate::planner::ROWID_STRS; use crate::translate::result_row::try_fold_expr_to_i64; @@ -432,25 +437,18 @@ fn emit_program_for_delete( }); } - let has_parent_fks = connection.foreign_keys_enabled() && { - let table_name = plan - .table_references - .joined_tables() - .first() - .unwrap() - .table - .get_name(); - resolver.schema.any_resolved_fks_referencing(table_name) - }; - // Open FK scope for the whole statement - if has_parent_fks { - program.emit_insn(Insn::FkCounter { - increment_value: 1, - check_abort: false, - is_scope: true, - }); + let fk_enabled = connection.foreign_keys_enabled(); + let table_name = plan + .table_references + .joined_tables() + .first() + .unwrap() + .table + .get_name() + .to_string(); + if fk_enabled { + emit_fk_scope_if_needed(program, resolver, &table_name, true)?; } - // Initialize cursors and other resources needed for query execution init_loop( program, @@ -489,12 +487,8 @@ fn emit_program_for_delete( None, )?; program.preassign_label_to_next_insn(after_main_loop_label); - if has_parent_fks { - program.emit_insn(Insn::FkCounter { - increment_value: -1, - check_abort: true, - is_scope: true, - }); + if fk_enabled { + emit_fk_scope_if_needed(program, resolver, &table_name, false)?; } // Finalize program program.result_columns = plan.result_columns; @@ -502,6 +496,169 @@ fn emit_program_for_delete( Ok(()) } +pub fn emit_fk_child_decrement_on_delete( + program: &mut ProgramBuilder, + resolver: &Resolver, + child_tbl: &BTreeTable, + child_table_name: &str, + child_cursor_id: usize, + child_rowid_reg: usize, +) -> crate::Result<()> { + for fk_ref in resolver.schema.resolved_fks_for_child(child_table_name)? { + if !fk_ref.fk.deferred { + continue; + } + // Fast path: if any FK column is NULL can't be a violation + let null_skip = program.allocate_label(); + for cname in &fk_ref.child_cols { + let (pos, col) = child_tbl.get_column(cname).unwrap(); + let src = if col.is_rowid_alias { + child_rowid_reg + } else { + let tmp = program.alloc_register(); + program.emit_insn(Insn::Column { + cursor_id: child_cursor_id, + column: pos, + dest: tmp, + default: None, + }); + tmp + }; + program.emit_insn(Insn::IsNull { + reg: src, + target_pc: null_skip, + }); + } + + if fk_ref.parent_uses_rowid { + // Probe parent table by rowid + let parent_tbl = resolver + .schema + .get_btree_table(&fk_ref.fk.parent_table) + .expect("parent btree"); + let pcur = program.alloc_cursor_id(CursorType::BTreeTable(parent_tbl.clone())); + program.emit_insn(Insn::OpenRead { + cursor_id: pcur, + root_page: parent_tbl.root_page, + db: 0, + }); + + let (pos, col) = child_tbl.get_column(&fk_ref.child_cols[0]).unwrap(); + let val = if col.is_rowid_alias { + child_rowid_reg + } else { + let tmp = program.alloc_register(); + program.emit_insn(Insn::Column { + cursor_id: child_cursor_id, + column: pos, + dest: tmp, + default: None, + }); + tmp + }; + let tmpi = program.alloc_register(); + program.emit_insn(Insn::Copy { + src_reg: val, + dst_reg: tmpi, + extra_amount: 0, + }); + program.emit_insn(Insn::MustBeInt { reg: tmpi }); + + // NotExists jumps when the parent key is missing, so we decrement there + let missing = program.allocate_label(); + let done = program.allocate_label(); + + program.emit_insn(Insn::NotExists { + cursor: pcur, + rowid_reg: tmpi, + target_pc: missing, + }); + + // Parent FOUND, no decrement + program.emit_insn(Insn::Close { cursor_id: pcur }); + program.emit_insn(Insn::Goto { target_pc: done }); + + // Parent MISSING, decrement is guarded by FkIfZero to avoid underflow + program.preassign_label_to_next_insn(missing); + program.emit_insn(Insn::Close { cursor_id: pcur }); + program.emit_insn(Insn::FkIfZero { + is_scope: false, + target_pc: done, + }); + program.emit_insn(Insn::FkCounter { + is_scope: false, + increment_value: -1, + }); + + program.preassign_label_to_next_insn(done); + } else { + // Probe parent unique index + let parent_tbl = resolver + .schema + .get_btree_table(&fk_ref.fk.parent_table) + .expect("parent btree"); + let idx = fk_ref.parent_unique_index.as_ref().expect("unique index"); + let icur = program.alloc_cursor_id(CursorType::BTreeIndex(idx.clone())); + program.emit_insn(Insn::OpenRead { + cursor_id: icur, + root_page: idx.root_page, + db: 0, + }); + + // Build probe from current child row + let n = fk_ref.child_cols.len(); + let probe = program.alloc_registers(n); + for (i, cname) in fk_ref.child_cols.iter().enumerate() { + let (pos, col) = child_tbl.get_column(cname).unwrap(); + let src = if col.is_rowid_alias { + child_rowid_reg + } else { + let r = program.alloc_register(); + program.emit_insn(Insn::Column { + cursor_id: child_cursor_id, + column: pos, + dest: r, + default: None, + }); + r + }; + program.emit_insn(Insn::Copy { + src_reg: src, + dst_reg: probe + i, + extra_amount: 0, + }); + } + program.emit_insn(Insn::Affinity { + start_reg: probe, + count: std::num::NonZeroUsize::new(n).unwrap(), + affinities: build_index_affinity_string(idx, &parent_tbl), + }); + + let ok = program.allocate_label(); + program.emit_insn(Insn::Found { + cursor_id: icur, + target_pc: ok, + record_reg: probe, + num_regs: n, + }); + program.emit_insn(Insn::Close { cursor_id: icur }); + program.emit_insn(Insn::FkIfZero { + is_scope: false, + target_pc: ok, + }); + program.emit_insn(Insn::FkCounter { + increment_value: -1, + is_scope: false, + }); + program.preassign_label_to_next_insn(ok); + program.emit_insn(Insn::Close { cursor_id: icur }); + } + + program.preassign_label_to_next_insn(null_skip); + } + Ok(()) +} + fn emit_delete_insns( connection: &Arc, program: &mut ProgramBuilder, @@ -540,20 +697,32 @@ fn emit_delete_insns( dest: key_reg, }); - if connection.foreign_keys_enabled() - && unsafe { &*table_reference }.btree().is_some() - && t_ctx - .resolver - .schema - .any_resolved_fks_referencing(table_name) - { - emit_fk_parent_existence_checks( - program, - &t_ctx.resolver, - table_name, - main_table_cursor_id, - key_reg, - )?; + if connection.foreign_keys_enabled() { + if let Some(table) = unsafe { &*table_reference }.btree() { + if t_ctx + .resolver + .schema + .any_resolved_fks_referencing(table_name) + { + emit_fk_delete_parent_existence_checks( + program, + &t_ctx.resolver, + table_name, + main_table_cursor_id, + key_reg, + )?; + } + if t_ctx.resolver.schema.has_child_fks(table_name) { + emit_fk_child_decrement_on_delete( + program, + &t_ctx.resolver, + &table, + table_name, + main_table_cursor_id, + key_reg, + )?; + } + } } if unsafe { &*table_reference }.virtual_table().is_some() { @@ -734,530 +903,6 @@ fn emit_delete_insns( Ok(()) } -/// Emit parent-side FK counter maintenance for UPDATE on a table with a composite PK. -/// -/// For every child FK that targets `parent_table_name`: -/// 1. Pass 1: If any child row currently references the OLD parent key, -/// increment the global FK counter (deferred violation potential). -/// We try an index probe on child(child_cols...) if available, else do a table scan. -/// 2. Pass 2: If any child row references the NEW parent key, decrement the counter -/// (because the reference would be “retargeted” by the update). -pub fn emit_fk_parent_pk_change_counters( - program: &mut ProgramBuilder, - incoming: &[ResolvedFkRef], - resolver: &Resolver, - old_pk_start: usize, - new_pk_start: usize, - n_cols: usize, -) -> crate::Result<()> { - if incoming.is_empty() { - return Ok(()); - } - for fk_ref in incoming.iter() { - let child_tbl = &fk_ref.child_table; - let child_cols = &fk_ref.fk.child_columns; - // Prefer exact-prefix index on child - let idx = resolver.schema.get_indices(&child_tbl.name).find(|ix| { - ix.columns.len() == child_cols.len() - && ix - .columns - .iter() - .zip(child_cols.iter()) - .all(|(ic, cc)| ic.name.eq_ignore_ascii_case(cc)) - }); - - if let Some(ix) = idx { - let icur = program.alloc_cursor_id(CursorType::BTreeIndex(ix.clone())); - program.emit_insn(Insn::OpenRead { - cursor_id: icur, - root_page: ix.root_page, - db: 0, - }); - - // Build child-probe key from OLD parent PK (1:1 map ensured by the column-name equality above) - // We just copy the OLD PK registers, apply index affinities before the probe. - let probe_start = old_pk_start; - - // Apply affinities for composite comparison - let aff: String = ix - .columns - .iter() - .map(|ic| { - let (_, col) = child_tbl - .get_column(&ic.name) - .expect("indexed child column not found"); - col.affinity().aff_mask() - }) - .collect(); - if let Some(count) = NonZeroUsize::new(n_cols) { - program.emit_insn(Insn::Affinity { - start_reg: probe_start, - count, - affinities: aff, - }); - } - - let found = program.allocate_label(); - program.emit_insn(Insn::Found { - cursor_id: icur, - target_pc: found, - record_reg: probe_start, - num_regs: n_cols, - }); - - // Not found => no increment - program.emit_insn(Insn::Close { cursor_id: icur }); - let skip = program.allocate_label(); - program.emit_insn(Insn::Goto { target_pc: skip }); - - // Found => increment - program.preassign_label_to_next_insn(found); - program.emit_insn(Insn::Close { cursor_id: icur }); - program.emit_insn(Insn::FkCounter { - increment_value: 1, - check_abort: false, - is_scope: false, - }); - program.preassign_label_to_next_insn(skip); - } else { - // Table-scan fallback with per-column checks (jump-if-NULL semantics) - let ccur = program.alloc_cursor_id(CursorType::BTreeTable(child_tbl.clone())); - program.emit_insn(Insn::OpenRead { - cursor_id: ccur, - root_page: child_tbl.root_page, - db: 0, - }); - - let done = program.allocate_label(); - program.emit_insn(Insn::Rewind { - cursor_id: ccur, - pc_if_empty: done, - }); - - let loop_top = program.allocate_label(); - let next_row = program.allocate_label(); - program.preassign_label_to_next_insn(loop_top); - - for (i, child_name) in child_cols.iter().enumerate() { - let (pos, _) = child_tbl.get_column(child_name).ok_or_else(|| { - crate::LimboError::InternalError(format!("child col {child_name} missing")) - })?; - let tmp = program.alloc_register(); - program.emit_insn(Insn::Column { - cursor_id: ccur, - column: pos, - dest: tmp, - default: None, - }); - - // Treat NULL as non-match: jump away immediately - program.emit_insn(Insn::IsNull { - reg: tmp, - target_pc: next_row, - }); - - // Eq(tmp, old_pk[i]) with Binary collation, jump-if-NULL enabled - let cont = program.allocate_label(); - program.emit_insn(Insn::Eq { - lhs: tmp, - rhs: old_pk_start + i, - target_pc: cont, - flags: CmpInsFlags::default().jump_if_null(), - collation: Some(super::collate::CollationSeq::Binary), - }); - program.emit_insn(Insn::Goto { - target_pc: next_row, - }); - program.preassign_label_to_next_insn(cont); - } - - // All columns matched OLD -> increment - program.emit_insn(Insn::FkCounter { - increment_value: 1, - check_abort: false, - is_scope: false, - }); - - program.preassign_label_to_next_insn(next_row); - program.emit_insn(Insn::Next { - cursor_id: ccur, - pc_if_next: loop_top, - }); - program.preassign_label_to_next_insn(done); - program.emit_insn(Insn::Close { cursor_id: ccur }); - } - } - - // PASS 2: count children of NEW key - for fk_ref in incoming.iter() { - let child_tbl = &fk_ref.child_table; - let child_cols = &fk_ref.fk.child_columns; - - let idx = resolver.schema.get_indices(&child_tbl.name).find(|ix| { - ix.columns.len() == child_cols.len() - && ix - .columns - .iter() - .zip(child_cols.iter()) - .all(|(ic, cc)| ic.name.eq_ignore_ascii_case(cc)) - }); - - if let Some(ix) = idx { - let icur = program.alloc_cursor_id(CursorType::BTreeIndex(ix.clone())); - program.emit_insn(Insn::OpenRead { - cursor_id: icur, - root_page: ix.root_page, - db: 0, - }); - - // Build probe from NEW PK registers; apply affinities - let probe_start = new_pk_start; - let aff: String = ix - .columns - .iter() - .map(|ic| { - let (_, col) = child_tbl - .get_column(&ic.name) - .expect("indexed child column not found"); - col.affinity().aff_mask() - }) - .collect(); - if let Some(count) = NonZeroUsize::new(n_cols) { - program.emit_insn(Insn::Affinity { - start_reg: probe_start, - count, - affinities: aff, - }); - } - - let found = program.allocate_label(); - program.emit_insn(Insn::Found { - cursor_id: icur, - target_pc: found, - record_reg: probe_start, - num_regs: n_cols, - }); - - // Not found => no decrement - program.emit_insn(Insn::Close { cursor_id: icur }); - let skip = program.allocate_label(); - program.emit_insn(Insn::Goto { target_pc: skip }); - - // Found => decrement - program.preassign_label_to_next_insn(found); - program.emit_insn(Insn::Close { cursor_id: icur }); - program.emit_insn(Insn::FkCounter { - increment_value: -1, - check_abort: false, - is_scope: false, - }); - program.preassign_label_to_next_insn(skip); - } else { - // Table-scan fallback on NEW key - let ccur = program.alloc_cursor_id(CursorType::BTreeTable(child_tbl.clone())); - program.emit_insn(Insn::OpenRead { - cursor_id: ccur, - root_page: child_tbl.root_page, - db: 0, - }); - - let done = program.allocate_label(); - program.emit_insn(Insn::Rewind { - cursor_id: ccur, - pc_if_empty: done, - }); - - let loop_top = program.allocate_label(); - let next_row = program.allocate_label(); - program.preassign_label_to_next_insn(loop_top); - - for (i, child_name) in child_cols.iter().enumerate() { - let (pos, _) = child_tbl.get_column(child_name).ok_or_else(|| { - crate::LimboError::InternalError(format!("child col {child_name} missing")) - })?; - let tmp = program.alloc_register(); - program.emit_insn(Insn::Column { - cursor_id: ccur, - column: pos, - dest: tmp, - default: None, - }); - - program.emit_insn(Insn::IsNull { - reg: tmp, - target_pc: next_row, - }); - - let cont = program.allocate_label(); - program.emit_insn(Insn::Eq { - lhs: tmp, - rhs: new_pk_start + i, - target_pc: cont, - flags: CmpInsFlags::default().jump_if_null(), - collation: Some(super::collate::CollationSeq::Binary), - }); - program.emit_insn(Insn::Goto { - target_pc: next_row, - }); - program.preassign_label_to_next_insn(cont); - } - - // All columns matched NEW: decrement - program.emit_insn(Insn::FkCounter { - increment_value: -1, - check_abort: false, - is_scope: false, - }); - - program.preassign_label_to_next_insn(next_row); - program.emit_insn(Insn::Next { - cursor_id: ccur, - pc_if_next: loop_top, - }); - program.preassign_label_to_next_insn(done); - program.emit_insn(Insn::Close { cursor_id: ccur }); - } - } - Ok(()) -} - -/// Emit checks that prevent updating/deleting a parent row that is still referenced by a child. -/// -/// If the global deferred-FK counter is zero, we skip all checks (fast path for no outstanding refs). -/// For each incoming FK: -/// Build the parent key (in FK parent-column order) from the current row. -/// Probe the child table for any row whose FK columns equal that key. -/// - If an exact child index exists on the FK columns, use `NotFound` against that index. -/// - Otherwise, scan the child table and compare each FK column (NULL short-circuits to “no match”). -/// If a referencing child is found: -/// - Deferred FK: increment counter (violation will be raised at COMMIT). -/// - Immediate FK: raise `SQLITE_CONSTRAINT_FOREIGNKEY` now. -pub fn emit_fk_parent_existence_checks( - program: &mut ProgramBuilder, - resolver: &Resolver, - parent_table_name: &str, - parent_cursor_id: usize, - parent_rowid_reg: usize, -) -> Result<()> { - let parent_bt = resolver - .schema - .get_btree_table(parent_table_name) - .ok_or_else(|| crate::LimboError::InternalError("parent not btree".into()))?; - - for fk_ref in resolver.schema.resolved_fks_referencing(parent_table_name) { - // Resolve parent key columns - let parent_cols: Vec = if fk_ref.fk.parent_columns.is_empty() { - parent_bt - .primary_key_columns - .iter() - .map(|(n, _)| n.clone()) - .collect() - } else { - fk_ref.fk.parent_columns.clone() - }; - - // Load parent key values for THIS row into regs, in parent_cols order - let parent_cols_len = parent_cols.len(); - let parent_key_start = program.alloc_registers(parent_cols_len); - for (i, pcol) in parent_cols.iter().enumerate() { - let src = if pcol.eq_ignore_ascii_case("rowid") { - parent_rowid_reg - } else { - let (pos, col) = parent_bt - .get_column(&normalize_ident(pcol)) - .ok_or_else(|| { - crate::LimboError::InternalError(format!("col {pcol} missing")) - })?; - if col.is_rowid_alias { - parent_rowid_reg - } else { - // read current cell's column value - program.emit_insn(Insn::Column { - cursor_id: parent_cursor_id, - column: pos, - dest: parent_key_start + i, - default: None, - }); - continue; - } - }; - program.emit_insn(Insn::Copy { - src_reg: src, - dst_reg: parent_key_start + i, - extra_amount: 0, - }); - } - - // Build child-side probe key in child_columns order, from parent_key_start - // - // Map parent_col to child_col position 1:1 - let child_cols = &fk_ref.fk.child_columns; - // Try to find an index on child(child_cols...) to do an existance check - let child_idx = resolver - .schema - .get_indices(&fk_ref.child_table.name) - .find(|idx| { - idx.columns.len() == child_cols.len() - && idx - .columns - .iter() - .zip(child_cols.iter()) - .all(|(ic, cc)| ic.name.eq_ignore_ascii_case(cc)) - }); - - if let Some(idx) = child_idx { - // Index existence probe: Found -> violation - let icur = program.alloc_cursor_id(CursorType::BTreeIndex(idx.clone())); - program.emit_insn(Insn::OpenRead { - cursor_id: icur, - root_page: idx.root_page, - db: 0, - }); - - // Pack the child key regs from the parent key regs in fk order. - // Same order because we matched columns 1:1 above - let probe_start = program.alloc_registers(parent_cols_len); - for i in 0..parent_cols_len { - program.emit_insn(Insn::Copy { - src_reg: parent_key_start + i, - dst_reg: probe_start + i, - extra_amount: 0, - }); - } - if let Some(count) = NonZeroUsize::new(parent_cols_len) { - // Apply index affinities for composite comparison - let aff: String = idx - .columns - .iter() - .map(|ic| { - let (_, col) = fk_ref - .child_table - .get_column(&ic.name) - .expect("indexed child column not found"); - col.affinity().aff_mask() - }) - .collect(); - program.emit_insn(Insn::Affinity { - start_reg: probe_start, - count, - affinities: aff, - }); - } - - let ok = program.allocate_label(); - program.emit_insn(Insn::NotFound { - cursor_id: icur, - target_pc: ok, - record_reg: probe_start, - num_regs: parent_cols_len, - }); - - // found referencing child row = violation path - program.emit_insn(Insn::Close { cursor_id: icur }); - if fk_ref.fk.deferred { - program.emit_insn(Insn::FkCounter { - increment_value: 1, - check_abort: false, - is_scope: false, - }); - } else { - program.emit_insn(Insn::Halt { - err_code: crate::error::SQLITE_CONSTRAINT_FOREIGNKEY, - description: "FOREIGN KEY constraint failed".to_string(), - }); - } - program.preassign_label_to_next_insn(ok); - program.emit_insn(Insn::Close { cursor_id: icur }); - } else { - // Fallback: table-scan the child table - let ccur = program.alloc_cursor_id(CursorType::BTreeTable(fk_ref.child_table.clone())); - program.emit_insn(Insn::OpenRead { - cursor_id: ccur, - root_page: fk_ref.child_table.root_page, - db: 0, - }); - - let done = program.allocate_label(); - program.emit_insn(Insn::Rewind { - cursor_id: ccur, - pc_if_empty: done, - }); - - // Loop labels local to this scan - let loop_top = program.allocate_label(); - let next_row = program.allocate_label(); - - program.preassign_label_to_next_insn(loop_top); - - // For each FK column: require a match, if NULL or mismatch -> next_row - for (i, child_col) in child_cols.iter().enumerate() { - let (pos, _) = fk_ref - .child_table - .get_column(&normalize_ident(child_col)) - .ok_or_else(|| { - crate::LimboError::InternalError(format!("child col {child_col} missing")) - })?; - - let tmp = program.alloc_register(); - program.emit_insn(Insn::Column { - cursor_id: ccur, - column: pos, - dest: tmp, - default: None, - }); - - // NULL FK value => this child row cannot reference the parent, skip row - program.emit_insn(Insn::IsNull { - reg: tmp, - target_pc: next_row, - }); - - // Equal? continue to check next column; else jump to next_row - let cont_i = program.allocate_label(); - program.emit_insn(Insn::Eq { - lhs: tmp, - rhs: parent_key_start + i, - target_pc: cont_i, - flags: CmpInsFlags::default().jump_if_null(), - collation: program.curr_collation(), - }); - // Not equal -> skip this child row - program.emit_insn(Insn::Goto { - target_pc: next_row, - }); - - // Equal path resumes here, then we check the next column - program.preassign_label_to_next_insn(cont_i); - } - - // If we reached here, all FK columns matched, violation - if fk_ref.fk.deferred { - program.emit_insn(Insn::FkCounter { - increment_value: 1, - check_abort: false, - is_scope: false, - }); - } else { - program.emit_insn(Insn::Halt { - err_code: crate::error::SQLITE_CONSTRAINT_FOREIGNKEY, - description: "FOREIGN KEY constraint failed".to_string(), - }); - } - - // Advance to next child row and loop - program.preassign_label_to_next_insn(next_row); - program.emit_insn(Insn::Next { - cursor_id: ccur, - pc_if_next: loop_top, - }); - - program.preassign_label_to_next_insn(done); - program.emit_insn(Insn::Close { cursor_id: ccur }); - } - } - Ok(()) -} - #[instrument(skip_all, level = Level::DEBUG)] fn emit_program_for_update( connection: &Arc, @@ -1309,16 +954,13 @@ fn emit_program_for_update( .first() .unwrap() .table - .get_name(); - let has_child_fks = fk_enabled && resolver.schema.has_child_fks(table_name); - let has_parent_fks = fk_enabled && resolver.schema.any_resolved_fks_referencing(table_name); + .get_name() + .to_string(); + // statement-level FK scope open - if has_child_fks || has_parent_fks { - program.emit_insn(Insn::FkCounter { - increment_value: 1, - check_abort: false, - is_scope: true, - }); + if fk_enabled { + let open = true; + emit_fk_scope_if_needed(program, resolver, &table_name, open)?; } // Initialize the main loop @@ -1387,13 +1029,9 @@ fn emit_program_for_update( )?; program.preassign_label_to_next_insn(after_main_loop_label); - - if has_child_fks || has_parent_fks { - program.emit_insn(Insn::FkCounter { - increment_value: -1, - check_abort: true, - is_scope: true, - }); + if fk_enabled { + let open = false; + emit_fk_scope_if_needed(program, resolver, &table_name, open)?; } after(program); @@ -1662,41 +1300,23 @@ fn emit_update_insns( if connection.foreign_keys_enabled() { let rowid_new_reg = rowid_set_clause_reg.unwrap_or(beg); if let Some(table_btree) = unsafe { &*table_ref }.btree() { - //first, stablize the image of the NEW row in the registers - if !table_btree.primary_key_columns.is_empty() { - let set_cols: std::collections::HashSet = plan - .set_clauses - .iter() - .filter_map(|(i, _)| if *i == ROWID_SENTINEL { None } else { Some(*i) }) - .collect(); - for (pk_name, _) in &table_btree.primary_key_columns { - let (pos, col) = table_btree.get_column(pk_name).unwrap(); - if !set_cols.contains(&pos) { - if col.is_rowid_alias { - program.emit_insn(Insn::Copy { - src_reg: rowid_new_reg, - dst_reg: start + pos, - extra_amount: 0, - }); - } else { - program.emit_insn(Insn::Column { - cursor_id, - column: pos, - dest: start + pos, - default: None, - }); - } - } - } - } + stabilize_new_row_for_fk( + program, + &table_btree, + &plan.set_clauses, + cursor_id, + start, + rowid_new_reg, + )?; if t_ctx.resolver.schema.has_child_fks(table_name) { // Child-side checks: // this ensures updated row still satisfies child FKs that point OUT from this table - emit_fk_child_existence_checks( + emit_fk_child_update_counters( program, &t_ctx.resolver, &table_btree, table_name, + cursor_id, start, rowid_new_reg, &plan @@ -1715,178 +1335,17 @@ fn emit_update_insns( .schema .any_resolved_fks_referencing(table_name) { - let updated_parent_positions: HashSet = - plan.set_clauses.iter().map(|(i, _)| *i).collect(); - - // If no incoming FK’s parent key can be affected by these updates, skip the whole parent-FK block. - let incoming = t_ctx.resolver.schema.resolved_fks_referencing(table_name); - let parent_tbl = &table_btree; - let maybe_affects_parent_key = incoming - .iter() - .any(|r| r.parent_key_may_change(&updated_parent_positions, parent_tbl)); - if maybe_affects_parent_key { - let pk_len = table_btree.primary_key_columns.len(); - match pk_len { - 0 => { - // Rowid table: the implicit PK is rowid. - // If rowid is unchanged then we skip, else check that no child row still references the OLD key. - let skip_parent_fk = program.allocate_label(); - let old_rowid_reg = beg; - let new_rowid_reg = rowid_set_clause_reg.unwrap_or(beg); - - program.emit_insn(Insn::Eq { - lhs: new_rowid_reg, - rhs: old_rowid_reg, - target_pc: skip_parent_fk, - flags: CmpInsFlags::default(), - collation: program.curr_collation(), - }); - // Rowid changed: check incoming FKs (children) that reference this parent row - emit_fk_parent_existence_checks( - program, - &t_ctx.resolver, - table_name, - cursor_id, - old_rowid_reg, - )?; - program.preassign_label_to_next_insn(skip_parent_fk); - } - 1 => { - // Single-column declared PK, may be a rowid alias or a real column. - // If PK value unchanged then skip, else verify no child still references OLD key. - let (pk_name, _) = &table_btree.primary_key_columns[0]; - let (pos, col) = table_btree.get_column(pk_name).unwrap(); - - let old_reg = program.alloc_register(); - if col.is_rowid_alias { - program.emit_insn(Insn::RowId { - cursor_id, - dest: old_reg, - }); - } else { - program.emit_insn(Insn::Column { - cursor_id, - column: pos, - dest: old_reg, - default: None, - }); - } - let new_reg = if col.is_rowid_alias { - rowid_new_reg - } else { - start + pos - }; - - let skip_parent_fk = program.allocate_label(); - program.emit_insn(Insn::Eq { - lhs: old_reg, - rhs: new_reg, - target_pc: skip_parent_fk, - flags: CmpInsFlags::default(), - collation: program.curr_collation(), - }); - emit_fk_parent_existence_checks( - program, - &t_ctx.resolver, - table_name, - cursor_id, - beg, - )?; - program.preassign_label_to_next_insn(skip_parent_fk); - } - _ => { - // Composite PK: - // 1. Materialize OLD PK vector from current row. - // 2. Materialize NEW PK vector from updated registers. - // 3. If any component differs, the PK changes -> run composite parent-FK update flow. - let old_pk_start = program.alloc_registers(pk_len); - for (i, (pk_name, _)) in - table_btree.primary_key_columns.iter().enumerate() - { - let (pos, col) = table_btree.get_column(pk_name).unwrap(); - if col.is_rowid_alias { - program.emit_insn(Insn::Copy { - src_reg: beg, - dst_reg: old_pk_start + i, - extra_amount: 0, - }); - } else { - program.emit_insn(Insn::Column { - cursor_id, - column: pos, - dest: old_pk_start + i, - default: None, - }); - } - } - - // Build NEW PK values from the updated registers - let new_pk_start = program.alloc_registers(pk_len); - for (i, (pk_name, _)) in - table_btree.primary_key_columns.iter().enumerate() - { - let (pos, col) = table_btree.get_column(pk_name).unwrap(); - let src = if col.is_rowid_alias { - rowid_new_reg - } else { - start + pos // Updated value from SET clause - }; - program.emit_insn(Insn::Copy { - src_reg: src, - dst_reg: new_pk_start + i, - extra_amount: 0, - }); - } - - // Compare OLD vs NEW to see if PK is changing - let skip_parent_fk = program.allocate_label(); - let pk_changed = program.allocate_label(); - - for i in 0..pk_len { - if i == pk_len - 1 { - // Last comparison, if equal, all are equal - program.emit_insn(Insn::Eq { - lhs: old_pk_start + i, - rhs: new_pk_start + i, - target_pc: skip_parent_fk, - flags: CmpInsFlags::default(), - collation: program.curr_collation(), - }); - // Not equal - PK is changing - program.emit_insn(Insn::Goto { - target_pc: pk_changed, - }); - } else { - // Not last comparison - let next_check = program.allocate_label(); - program.emit_insn(Insn::Eq { - lhs: old_pk_start + i, - rhs: new_pk_start + i, - target_pc: next_check, // Equal, check next component - flags: CmpInsFlags::default(), - collation: program.curr_collation(), - }); - // Not equal - PK is changing - program.emit_insn(Insn::Goto { - target_pc: pk_changed, - }); - program.preassign_label_to_next_insn(next_check); - } - } - program.preassign_label_to_next_insn(pk_changed); - // PK changed: maintain the deferred FK counter in two passes - emit_fk_parent_pk_change_counters( - program, - &incoming, - &t_ctx.resolver, - old_pk_start, - new_pk_start, - pk_len, - )?; - program.preassign_label_to_next_insn(skip_parent_fk); - } - } - } + emit_parent_pk_change_checks( + program, + &t_ctx.resolver, + &table_btree, + cursor_id, + beg, + start, + rowid_new_reg, + rowid_set_clause_reg, + &plan.set_clauses, + )?; } } } @@ -2342,160 +1801,6 @@ fn emit_update_insns( Ok(()) } -pub fn emit_fk_child_existence_checks( - program: &mut ProgramBuilder, - resolver: &Resolver, - table: &BTreeTable, - table_name: &str, - start_reg: usize, - rowid_reg: usize, - updated_cols: &HashSet, -) -> Result<()> { - for fk_ref in resolver.schema.resolved_fks_for_child(table_name) { - // Skip when the child key is untouched (including rowid-alias special case) - if !fk_ref.child_key_changed(updated_cols, table) { - continue; - } - - let fk_ok = program.allocate_label(); - - // look for NULLs in any child FK column - for child_name in &fk_ref.child_cols { - let (i, col) = table.get_column(child_name).unwrap(); - let src = if col.is_rowid_alias { - rowid_reg - } else { - start_reg + i - }; - program.emit_insn(Insn::IsNull { - reg: src, - target_pc: fk_ok, - }); - } - - if fk_ref.parent_uses_rowid { - // Fast rowid probe on the parent table - let parent_tbl = resolver - .schema - .get_btree_table(&fk_ref.fk.parent_table) - .expect("Parent must be btree"); - - let pcur = program.alloc_cursor_id(CursorType::BTreeTable(parent_tbl.clone())); - program.emit_insn(Insn::OpenRead { - cursor_id: pcur, - root_page: parent_tbl.root_page, - db: 0, - }); - - let (i_child, col_child) = table.get_column(&fk_ref.child_cols[0]).unwrap(); - let val_reg = if col_child.is_rowid_alias { - rowid_reg - } else { - start_reg + i_child - }; - let tmp = program.alloc_register(); - program.emit_insn(Insn::Copy { - src_reg: val_reg, - dst_reg: tmp, - extra_amount: 0, - }); - program.emit_insn(Insn::MustBeInt { reg: tmp }); - let violation = program.allocate_label(); - program.emit_insn(Insn::NotExists { - cursor: pcur, - rowid_reg: tmp, - target_pc: violation, - }); - program.emit_insn(Insn::Close { cursor_id: pcur }); - program.emit_insn(Insn::Goto { target_pc: fk_ok }); - - program.preassign_label_to_next_insn(violation); - program.emit_insn(Insn::Close { cursor_id: pcur }); - if fk_ref.fk.deferred { - program.emit_insn(Insn::FkCounter { - increment_value: 1, - check_abort: false, - is_scope: false, - }); - } else { - program.emit_insn(Insn::Halt { - err_code: crate::error::SQLITE_CONSTRAINT_FOREIGNKEY, - description: "FOREIGN KEY constraint failed".to_string(), - }); - } - } else { - // Unique-index probe on the parent (already resolved) - let parent_idx = fk_ref - .parent_unique_index - .as_ref() - .expect("parent unique index required"); - let icur = program.alloc_cursor_id(CursorType::BTreeIndex(parent_idx.clone())); - program.emit_insn(Insn::OpenRead { - cursor_id: icur, - root_page: parent_idx.root_page, - db: 0, - }); - - // Build probe key from NEW child values in fk order - let n = fk_ref.child_cols.len(); - let probe_start = program.alloc_registers(n); - for (k, child_name) in fk_ref.child_cols.iter().enumerate() { - let (i, col) = table.get_column(child_name).unwrap(); - program.emit_insn(Insn::Copy { - src_reg: if col.is_rowid_alias { - rowid_reg - } else { - start_reg + i - }, - dst_reg: probe_start + k, - extra_amount: 0, - }); - } - - let aff: String = parent_idx - .columns - .iter() - .map(|ic| table.columns[ic.pos_in_table].affinity().aff_mask()) - .collect(); - program.emit_insn(Insn::Affinity { - start_reg: probe_start, - count: NonZeroUsize::new(n).unwrap(), - affinities: aff, - }); - let found = program.allocate_label(); - program.emit_insn(Insn::Found { - cursor_id: icur, - target_pc: found, - record_reg: probe_start, - num_regs: n, - }); - - // Not found => violation - program.emit_insn(Insn::Close { cursor_id: icur }); - if fk_ref.fk.deferred { - program.emit_insn(Insn::FkCounter { - increment_value: 1, - check_abort: false, - is_scope: false, - }); - } else { - program.emit_insn(Insn::Halt { - err_code: crate::error::SQLITE_CONSTRAINT_FOREIGNKEY, - description: "FOREIGN KEY constraint failed".to_string(), - }); - } - program.emit_insn(Insn::Goto { target_pc: fk_ok }); - - // Found => OK - program.preassign_label_to_next_insn(found); - program.emit_insn(Insn::Close { cursor_id: icur }); - } - - program.preassign_label_to_next_insn(fk_ok); - } - Ok(()) -} - pub fn prepare_cdc_if_necessary( program: &mut ProgramBuilder, schema: &Schema, diff --git a/core/translate/fkeys.rs b/core/translate/fkeys.rs new file mode 100644 index 000000000..b2b356b37 --- /dev/null +++ b/core/translate/fkeys.rs @@ -0,0 +1,1025 @@ +use turso_parser::ast::Expr; + +use super::ProgramBuilder; +use crate::{ + schema::{BTreeTable, ForeignKey, Index, ResolvedFkRef, ROWID_SENTINEL}, + translate::{emitter::Resolver, planner::ROWID_STRS}, + vdbe::{ + builder::CursorType, + insn::{CmpInsFlags, Insn}, + }, + Result, +}; +use std::{collections::HashSet, num::NonZeroUsize, sync::Arc}; + +#[inline] +/// Increment/decrement the FK scope counter if `table_name` has either outgoing or incoming FKs. +/// +/// Returns `true` if a scope change was emitted. Scope open (+1) occurs before a statement +/// touching the table; scope close (−1) occurs after. On scope close, remaining deferred +/// violations are raised by the runtime. +pub fn emit_fk_scope_if_needed( + program: &mut ProgramBuilder, + resolver: &Resolver, + table_name: &str, + open: bool, +) -> Result { + let has_fks = resolver.schema.has_child_fks(table_name) + || resolver.schema.any_resolved_fks_referencing(table_name); + if has_fks { + program.emit_insn(Insn::FkCounter { + increment_value: if open { 1 } else { -1 }, + is_scope: true, + }); + } + Ok(has_fks) +} + +/// Open a read cursor on an index and return its cursor id. +#[inline] +pub fn open_read_index(program: &mut ProgramBuilder, idx: &Arc) -> usize { + let icur = program.alloc_cursor_id(CursorType::BTreeIndex(idx.clone())); + program.emit_insn(Insn::OpenRead { + cursor_id: icur, + root_page: idx.root_page, + db: 0, + }); + icur +} + +/// Open a read cursor on a table and return its cursor id. +#[inline] +pub fn open_read_table(program: &mut ProgramBuilder, tbl: &Arc) -> usize { + let tcur = program.alloc_cursor_id(CursorType::BTreeTable(tbl.clone())); + program.emit_insn(Insn::OpenRead { + cursor_id: tcur, + root_page: tbl.root_page, + db: 0, + }); + tcur +} + +/// Copy `len` registers starting at `src_start` to a fresh block and apply index affinities. +/// Returns the destination start register. +#[inline] +fn copy_with_affinity( + program: &mut ProgramBuilder, + src_start: usize, + len: usize, + idx: &Index, + aff_from_tbl: &BTreeTable, +) -> usize { + let dst = program.alloc_registers(len); + for i in 0..len { + program.emit_insn(Insn::Copy { + src_reg: src_start + i, + dst_reg: dst + i, + extra_amount: 0, + }); + } + if let Some(count) = NonZeroUsize::new(len) { + program.emit_insn(Insn::Affinity { + start_reg: dst, + count, + affinities: build_index_affinity_string(idx, aff_from_tbl), + }); + } + dst +} + +/// Issue an index probe using `Found`/`NotFound` and route to `on_found`/`on_not_found`. +pub fn index_probe( + program: &mut ProgramBuilder, + icur: usize, + record_reg: usize, + num_regs: usize, + mut on_found: F, + mut on_not_found: G, +) -> Result<()> +where + F: FnMut(&mut ProgramBuilder) -> Result<()>, + G: FnMut(&mut ProgramBuilder) -> Result<()>, +{ + let lbl_found = program.allocate_label(); + let lbl_join = program.allocate_label(); + + program.emit_insn(Insn::Found { + cursor_id: icur, + target_pc: lbl_found, + record_reg, + num_regs, + }); + + // NOT FOUND path + on_not_found(program)?; + program.emit_insn(Insn::Goto { + target_pc: lbl_join, + }); + + // FOUND path + program.preassign_label_to_next_insn(lbl_found); + on_found(program)?; + + // Join & close once + program.preassign_label_to_next_insn(lbl_join); + program.emit_insn(Insn::Close { cursor_id: icur }); + Ok(()) +} + +/// Iterate a table and call `on_match` when all child columns equal the key at `parent_key_start`. +/// Skips rows where any FK column is NULL. If `self_exclude_rowid` is Some, the row with that rowid is skipped. +fn table_scan_match_any( + program: &mut ProgramBuilder, + child_tbl: &Arc, + child_cols: &[String], + parent_key_start: usize, + self_exclude_rowid: Option, + mut on_match: F, +) -> Result<()> +where + F: FnMut(&mut ProgramBuilder) -> Result<()>, +{ + let ccur = open_read_table(program, child_tbl); + let done = program.allocate_label(); + program.emit_insn(Insn::Rewind { + cursor_id: ccur, + pc_if_empty: done, + }); + + let loop_top = program.allocate_label(); + program.preassign_label_to_next_insn(loop_top); + let next_row = program.allocate_label(); + + // Compare each FK column to parent key component. + for (i, cname) in child_cols.iter().enumerate() { + let (pos, _) = child_tbl.get_column(cname).ok_or_else(|| { + crate::LimboError::InternalError(format!("child col {cname} missing")) + })?; + let tmp = program.alloc_register(); + program.emit_insn(Insn::Column { + cursor_id: ccur, + column: pos, + dest: tmp, + default: None, + }); + program.emit_insn(Insn::IsNull { + reg: tmp, + target_pc: next_row, + }); + + let cont = program.allocate_label(); + program.emit_insn(Insn::Eq { + lhs: tmp, + rhs: parent_key_start + i, + target_pc: cont, + flags: CmpInsFlags::default().jump_if_null(), + collation: Some(super::collate::CollationSeq::Binary), + }); + program.emit_insn(Insn::Goto { + target_pc: next_row, + }); + program.preassign_label_to_next_insn(cont); + } + + //self-reference exclusion on rowid + if let Some(parent_rowid) = self_exclude_rowid { + let child_rowid = program.alloc_register(); + let skip = program.allocate_label(); + program.emit_insn(Insn::RowId { + cursor_id: ccur, + dest: child_rowid, + }); + program.emit_insn(Insn::Eq { + lhs: child_rowid, + rhs: parent_rowid, + target_pc: skip, + flags: CmpInsFlags::default(), + collation: None, + }); + on_match(program)?; + program.preassign_label_to_next_insn(skip); + } else { + on_match(program)?; + } + + program.preassign_label_to_next_insn(next_row); + program.emit_insn(Insn::Next { + cursor_id: ccur, + pc_if_next: loop_top, + }); + + program.preassign_label_to_next_insn(done); + program.emit_insn(Insn::Close { cursor_id: ccur }); + Ok(()) +} + +/// Build the index affinity mask string (one char per indexed column). +#[inline] +pub fn build_index_affinity_string(idx: &Index, table: &BTreeTable) -> String { + idx.columns + .iter() + .map(|ic| table.columns[ic.pos_in_table].affinity().aff_mask()) + .collect() +} + +/// For deferred FKs: increment the global counter; for immediate FKs: halt with FK error. +pub fn emit_fk_violation(program: &mut ProgramBuilder, fk: &ForeignKey) -> Result<()> { + if fk.deferred { + program.emit_insn(Insn::FkCounter { + increment_value: 1, + is_scope: false, + }); + } else { + program.emit_insn(Insn::Halt { + err_code: crate::error::SQLITE_CONSTRAINT_FOREIGNKEY, + description: "FOREIGN KEY constraint failed".to_string(), + }); + } + Ok(()) +} + +/// Stabilize the NEW row image for FK checks (UPDATE): +/// fill in unmodified PK columns from the current row so the NEW PK vector is complete. +pub fn stabilize_new_row_for_fk( + program: &mut ProgramBuilder, + table_btree: &BTreeTable, + set_clauses: &[(usize, Box)], + cursor_id: usize, + start: usize, + rowid_new_reg: usize, +) -> Result<()> { + if table_btree.primary_key_columns.is_empty() { + return Ok(()); + } + let set_cols: HashSet = set_clauses + .iter() + .filter_map(|(i, _)| if *i == ROWID_SENTINEL { None } else { Some(*i) }) + .collect(); + + for (pk_name, _) in &table_btree.primary_key_columns { + let (pos, col) = table_btree + .get_column(pk_name) + .ok_or_else(|| crate::LimboError::InternalError(format!("pk col {pk_name} missing")))?; + if !set_cols.contains(&pos) { + if col.is_rowid_alias { + program.emit_insn(Insn::Copy { + src_reg: rowid_new_reg, + dst_reg: start + pos, + extra_amount: 0, + }); + } else { + program.emit_insn(Insn::Column { + cursor_id, + column: pos, + dest: start + pos, + default: None, + }); + } + } + } + Ok(()) +} + +/// Parent-side checks when the parent PK might change (UPDATE on parent): +/// Detect if any child references the OLD key (potential violation), and if any references the NEW key +/// (which cancels one potential violation). For composite PKs this builds OLD/NEW vectors first. +#[allow(clippy::too_many_arguments)] +pub fn emit_parent_pk_change_checks( + program: &mut ProgramBuilder, + resolver: &Resolver, + table_btree: &BTreeTable, + cursor_id: usize, + old_rowid_reg: usize, + start: usize, + rowid_new_reg: usize, + rowid_set_clause_reg: Option, + set_clauses: &[(usize, Box)], +) -> Result<()> { + let updated_positions: HashSet = set_clauses.iter().map(|(i, _)| *i).collect(); + let incoming = resolver + .schema + .resolved_fks_referencing(&table_btree.name)?; + let affects_pk = incoming + .iter() + .any(|r| r.parent_key_may_change(&updated_positions, table_btree)); + if !affects_pk { + return Ok(()); + } + + match table_btree.primary_key_columns.len() { + 0 => emit_rowid_pk_change_check( + program, + &incoming, + resolver, + old_rowid_reg, + rowid_set_clause_reg.unwrap_or(old_rowid_reg), + ), + 1 => emit_single_pk_change_check( + program, + &incoming, + resolver, + table_btree, + cursor_id, + start, + rowid_new_reg, + ), + _ => emit_composite_pk_change_check( + program, + &incoming, + resolver, + table_btree, + cursor_id, + old_rowid_reg, + start, + rowid_new_reg, + ), + } +} + +/// Rowid-table parent PK change: compare rowid OLD vs NEW; if changed, run two-pass counters. +pub fn emit_rowid_pk_change_check( + program: &mut ProgramBuilder, + incoming: &[ResolvedFkRef], + resolver: &Resolver, + old_rowid_reg: usize, + new_rowid_reg: usize, +) -> Result<()> { + let skip = program.allocate_label(); + program.emit_insn(Insn::Eq { + lhs: new_rowid_reg, + rhs: old_rowid_reg, + target_pc: skip, + flags: CmpInsFlags::default(), + collation: None, + }); + + let old_pk = program.alloc_register(); + let new_pk = program.alloc_register(); + program.emit_insn(Insn::Copy { + src_reg: old_rowid_reg, + dst_reg: old_pk, + extra_amount: 0, + }); + program.emit_insn(Insn::Copy { + src_reg: new_rowid_reg, + dst_reg: new_pk, + extra_amount: 0, + }); + + emit_fk_parent_pk_change_counters(program, incoming, resolver, old_pk, new_pk, 1)?; + program.preassign_label_to_next_insn(skip); + Ok(()) +} + +/// Single-column PK parent change: load OLD and NEW; if changed, run two-pass counters. +pub fn emit_single_pk_change_check( + program: &mut ProgramBuilder, + incoming: &[ResolvedFkRef], + resolver: &Resolver, + table_btree: &BTreeTable, + cursor_id: usize, + start: usize, + rowid_new_reg: usize, +) -> Result<()> { + let (pk_name, _) = &table_btree.primary_key_columns[0]; + let (pos, col) = table_btree.get_column(pk_name).unwrap(); + + let old_reg = program.alloc_register(); + if col.is_rowid_alias { + program.emit_insn(Insn::RowId { + cursor_id, + dest: old_reg, + }); + } else { + program.emit_insn(Insn::Column { + cursor_id, + column: pos, + dest: old_reg, + default: None, + }); + } + let new_reg = if col.is_rowid_alias { + rowid_new_reg + } else { + start + pos + }; + + let skip = program.allocate_label(); + program.emit_insn(Insn::Eq { + lhs: old_reg, + rhs: new_reg, + target_pc: skip, + flags: CmpInsFlags::default(), + collation: None, + }); + + let old_pk = program.alloc_register(); + let new_pk = program.alloc_register(); + program.emit_insn(Insn::Copy { + src_reg: old_reg, + dst_reg: old_pk, + extra_amount: 0, + }); + program.emit_insn(Insn::Copy { + src_reg: new_reg, + dst_reg: new_pk, + extra_amount: 0, + }); + + emit_fk_parent_pk_change_counters(program, incoming, resolver, old_pk, new_pk, 1)?; + program.preassign_label_to_next_insn(skip); + Ok(()) +} + +/// Composite-PK parent change: build OLD/NEW vectors; if any component differs, run two-pass counters. +#[allow(clippy::too_many_arguments)] +pub fn emit_composite_pk_change_check( + program: &mut ProgramBuilder, + incoming: &[ResolvedFkRef], + resolver: &Resolver, + table_btree: &BTreeTable, + cursor_id: usize, + old_rowid_reg: usize, + start: usize, + rowid_new_reg: usize, +) -> Result<()> { + let pk_len = table_btree.primary_key_columns.len(); + + let old_pk = program.alloc_registers(pk_len); + for (i, (pk_name, _)) in table_btree.primary_key_columns.iter().enumerate() { + let (pos, col) = table_btree.get_column(pk_name).unwrap(); + if col.is_rowid_alias { + program.emit_insn(Insn::Copy { + src_reg: old_rowid_reg, + dst_reg: old_pk + i, + extra_amount: 0, + }); + } else { + program.emit_insn(Insn::Column { + cursor_id, + column: pos, + dest: old_pk + i, + default: None, + }); + } + } + let new_pk = program.alloc_registers(pk_len); + for (i, (pk_name, _)) in table_btree.primary_key_columns.iter().enumerate() { + let (pos, col) = table_btree.get_column(pk_name).unwrap(); + let src = if col.is_rowid_alias { + rowid_new_reg + } else { + start + pos + }; + program.emit_insn(Insn::Copy { + src_reg: src, + dst_reg: new_pk + i, + extra_amount: 0, + }); + } + + let skip = program.allocate_label(); + let changed = program.allocate_label(); + for i in 0..pk_len { + let next = if i + 1 == pk_len { + None + } else { + Some(program.allocate_label()) + }; + program.emit_insn(Insn::Eq { + lhs: old_pk + i, + rhs: new_pk + i, + target_pc: next.unwrap_or(skip), + flags: CmpInsFlags::default(), + collation: None, + }); + program.emit_insn(Insn::Goto { target_pc: changed }); + if let Some(n) = next { + program.preassign_label_to_next_insn(n); + } + } + + program.preassign_label_to_next_insn(changed); + emit_fk_parent_pk_change_counters(program, incoming, resolver, old_pk, new_pk, pk_len)?; + program.preassign_label_to_next_insn(skip); + Ok(()) +} + +/// Two-pass parent-side maintenance for UPDATE of a parent key: +/// 1. Probe child for OLD key, increment deferred counter if any references exist. +/// 2. Probe child for NEW key, guarded decrement cancels exactly one increment if present +pub fn emit_fk_parent_pk_change_counters( + program: &mut ProgramBuilder, + incoming: &[ResolvedFkRef], + resolver: &Resolver, + old_pk_start: usize, + new_pk_start: usize, + n_cols: usize, +) -> Result<()> { + for fk_ref in incoming { + emit_fk_parent_key_probe( + program, + resolver, + fk_ref, + old_pk_start, + n_cols, + ParentProbePass::Old, + )?; + emit_fk_parent_key_probe( + program, + resolver, + fk_ref, + new_pk_start, + n_cols, + ParentProbePass::New, + )?; + } + Ok(()) +} + +#[derive(Clone, Copy)] +enum ParentProbePass { + Old, + New, +} + +/// Probe the child side for a given parent key. If `increment_value` is +1, increment counter on match. +/// If −1, we guard with `FkIfZero` then decrement to avoid counter underflow in edge cases. +fn emit_fk_parent_key_probe( + program: &mut ProgramBuilder, + resolver: &Resolver, + fk_ref: &ResolvedFkRef, + parent_key_start: usize, + n_cols: usize, + pass: ParentProbePass, +) -> Result<()> { + let child_tbl = &fk_ref.child_table; + let child_cols = &fk_ref.fk.child_columns; + let is_deferred = fk_ref.fk.deferred; + + let on_match = |p: &mut ProgramBuilder| -> Result<()> { + match (is_deferred, pass) { + // OLD key referenced by a child + (false, ParentProbePass::Old) => { + // Immediate FK: fail now. + emit_fk_violation(p, &fk_ref.fk)?; // HALT for immediate + } + (true, ParentProbePass::Old) => { + // Deferred FK: increment counter. + p.emit_insn(Insn::FkCounter { + increment_value: 1, + is_scope: false, + }); + } + + // NEW key referenced by a child (cancel one deferred violation) + (true, ParentProbePass::New) => { + // Guard to avoid underflow if OLD pass didn't increment. + let skip = p.allocate_label(); + p.emit_insn(Insn::FkIfZero { + is_scope: false, + target_pc: skip, + }); + p.emit_insn(Insn::FkCounter { + increment_value: -1, + is_scope: false, + }); + p.preassign_label_to_next_insn(skip); + } + // Immediate FK on NEW pass: nothing to cancel; do nothing. + (false, ParentProbePass::New) => {} + } + Ok(()) + }; + + // Prefer exact child index on (child_cols...) + let idx = resolver.schema.get_indices(&child_tbl.name).find(|ix| { + ix.columns.len() == child_cols.len() + && ix + .columns + .iter() + .zip(child_cols.iter()) + .all(|(ic, cc)| ic.name.eq_ignore_ascii_case(cc)) + }); + + if let Some(ix) = idx { + let icur = open_read_index(program, ix); + let probe = copy_with_affinity(program, parent_key_start, n_cols, ix, child_tbl); + + // FOUND => on_match; NOT FOUND => no-op + index_probe(program, icur, probe, n_cols, on_match, |_p| Ok(()))?; + } else { + // Table scan fallback + table_scan_match_any( + program, + child_tbl, + child_cols, + parent_key_start, + None, + on_match, + )?; + } + + Ok(()) +} + +/// Build a parent key vector (in FK parent-column order) into `dest_start`. +/// Handles rowid aliasing and explicit ROWID names; uses current row for non-rowid columns. +fn build_parent_key( + program: &mut ProgramBuilder, + parent_bt: &BTreeTable, + parent_cols: &[String], + parent_cursor_id: usize, + parent_rowid_reg: usize, + dest_start: usize, +) -> Result<()> { + for (i, pcol) in parent_cols.iter().enumerate() { + let src = if ROWID_STRS.iter().any(|s| pcol.eq_ignore_ascii_case(s)) { + parent_rowid_reg + } else { + let (pos, col) = parent_bt + .get_column(pcol) + .ok_or_else(|| crate::LimboError::InternalError(format!("col {pcol} missing")))?; + if col.is_rowid_alias { + parent_rowid_reg + } else { + program.emit_insn(Insn::Column { + cursor_id: parent_cursor_id, + column: pos, + dest: dest_start + i, + default: None, + }); + continue; + } + }; + program.emit_insn(Insn::Copy { + src_reg: src, + dst_reg: dest_start + i, + extra_amount: 0, + }); + } + Ok(()) +} + +/// Child-side FK maintenance for UPDATE/UPSERT: +/// If any FK columns of this child row changed: +/// Pass 1 (OLD tuple): if OLD is non-NULL and parent is missing → decrement deferred counter (guarded). +/// Pass 2 (NEW tuple): if NEW is non-NULL and parent is missing → immediate error or deferred(+1). +#[allow(clippy::too_many_arguments)] +pub fn emit_fk_child_update_counters( + program: &mut ProgramBuilder, + resolver: &Resolver, + child_tbl: &BTreeTable, + child_table_name: &str, + child_cursor_id: usize, + new_start_reg: usize, + new_rowid_reg: usize, + updated_cols: &HashSet, +) -> crate::Result<()> { + // Helper: materialize OLD tuple for this FK; returns (start_reg, ncols) or None if any component is NULL. + let load_old_tuple = + |program: &mut ProgramBuilder, fk_cols: &[String]| -> Option<(usize, usize)> { + let n = fk_cols.len(); + let start = program.alloc_registers(n); + let null_jmp = program.allocate_label(); + + for (k, cname) in fk_cols.iter().enumerate() { + let (pos, _col) = match child_tbl.get_column(cname) { + Some(v) => v, + None => { + // schema inconsistency; treat as no-old tuple + return None; + } + }; + program.emit_column_or_rowid(child_cursor_id, pos, start + k); + program.emit_insn(Insn::IsNull { + reg: start + k, + target_pc: null_jmp, + }); + } + + // No NULLs, proceed + let cont = program.allocate_label(); + program.emit_insn(Insn::Goto { target_pc: cont }); + // NULL encountered -> invalidate tuple by jumping here + program.preassign_label_to_next_insn(null_jmp); + + program.preassign_label_to_next_insn(cont); + Some((start, n)) + }; + + for fk_ref in resolver.schema.resolved_fks_for_child(child_table_name)? { + // If the child-side FK columns did not change, there is nothing to do. + if !fk_ref.child_key_changed(updated_cols, child_tbl) { + continue; + } + + let ncols = fk_ref.child_cols.len(); + + // Pass 1: OLD tuple handling only for deferred FKs + if fk_ref.fk.deferred { + if let Some((old_start, _)) = load_old_tuple(program, &fk_ref.child_cols) { + if fk_ref.parent_uses_rowid { + // Parent key is rowid: probe parent table by rowid + let parent_tbl = resolver + .schema + .get_btree_table(&fk_ref.fk.parent_table) + .expect("parent btree"); + let pcur = open_read_table(program, &parent_tbl); + + // first FK col is the rowid value + let rid = program.alloc_register(); + program.emit_insn(Insn::Copy { + src_reg: old_start, + dst_reg: rid, + extra_amount: 0, + }); + program.emit_insn(Insn::MustBeInt { reg: rid }); + + // If NOT exists => decrement (guarded) + let miss = program.allocate_label(); + program.emit_insn(Insn::NotExists { + cursor: pcur, + rowid_reg: rid, + target_pc: miss, + }); + // found → close & continue + let join = program.allocate_label(); + program.emit_insn(Insn::Close { cursor_id: pcur }); + program.emit_insn(Insn::Goto { target_pc: join }); + + // missing → guarded decrement + program.preassign_label_to_next_insn(miss); + program.emit_insn(Insn::Close { cursor_id: pcur }); + let skip = program.allocate_label(); + program.emit_insn(Insn::FkIfZero { + is_scope: false, + target_pc: skip, + }); + program.emit_insn(Insn::FkCounter { + is_scope: false, + increment_value: -1, + }); + program.preassign_label_to_next_insn(skip); + + program.preassign_label_to_next_insn(join); + } else { + // Parent key is a unique index: use index probe and guarded decrement on NOT FOUND + let parent_tbl = resolver + .schema + .get_btree_table(&fk_ref.fk.parent_table) + .expect("parent btree"); + let idx = fk_ref + .parent_unique_index + .as_ref() + .expect("parent unique index required"); + let icur = open_read_index(program, idx); + + // Copy OLD tuple and apply parent index affinities + let probe = copy_with_affinity(program, old_start, ncols, idx, &parent_tbl); + // Found: nothing; Not found: guarded decrement + index_probe( + program, + icur, + probe, + ncols, + |_p| Ok(()), + |p| { + let skip = p.allocate_label(); + p.emit_insn(Insn::FkIfZero { + is_scope: false, + target_pc: skip, + }); + p.emit_insn(Insn::FkCounter { + is_scope: false, + increment_value: -1, + }); + p.preassign_label_to_next_insn(skip); + Ok(()) + }, + )?; + } + } + } + + // Pass 2: NEW tuple handling + // If any NEW component is NULL → FK is satisfied vacuously. + let fk_ok = program.allocate_label(); + for cname in &fk_ref.fk.child_columns { + let (i, col) = child_tbl.get_column(cname).unwrap(); + let src = if col.is_rowid_alias { + new_rowid_reg + } else { + new_start_reg + i + }; + program.emit_insn(Insn::IsNull { + reg: src, + target_pc: fk_ok, + }); + } + + if fk_ref.parent_uses_rowid { + let parent_tbl = resolver + .schema + .get_btree_table(&fk_ref.fk.parent_table) + .expect("parent btree"); + let pcur = open_read_table(program, &parent_tbl); + + // Take the first child column value (rowid) from NEW image + let (i_child, col_child) = child_tbl.get_column(&fk_ref.child_cols[0]).unwrap(); + let val_reg = if col_child.is_rowid_alias { + new_rowid_reg + } else { + new_start_reg + i_child + }; + + let tmp = program.alloc_register(); + program.emit_insn(Insn::Copy { + src_reg: val_reg, + dst_reg: tmp, + extra_amount: 0, + }); + program.emit_insn(Insn::MustBeInt { reg: tmp }); + + let violation = program.allocate_label(); + program.emit_insn(Insn::NotExists { + cursor: pcur, + rowid_reg: tmp, + target_pc: violation, + }); + // found → close and continue + program.emit_insn(Insn::Close { cursor_id: pcur }); + program.emit_insn(Insn::Goto { target_pc: fk_ok }); + + // missing → violation (immediate HALT or deferred +1) + program.preassign_label_to_next_insn(violation); + program.emit_insn(Insn::Close { cursor_id: pcur }); + emit_fk_violation(program, &fk_ref.fk)?; + } else { + let parent_tbl = resolver + .schema + .get_btree_table(&fk_ref.fk.parent_table) + .expect("parent btree"); + let idx = fk_ref + .parent_unique_index + .as_ref() + .expect("parent unique index required"); + let icur = open_read_index(program, idx); + + // Build NEW probe (in FK child column order → aligns with parent index columns) + let probe = { + let start = program.alloc_registers(ncols); + for (k, cname) in fk_ref.child_cols.iter().enumerate() { + let (i, col) = child_tbl.get_column(cname).unwrap(); + program.emit_insn(Insn::Copy { + src_reg: if col.is_rowid_alias { + new_rowid_reg + } else { + new_start_reg + i + }, + dst_reg: start + k, + extra_amount: 0, + }); + } + // Apply affinities of the parent index/table + if let Some(cnt) = NonZeroUsize::new(ncols) { + program.emit_insn(Insn::Affinity { + start_reg: start, + count: cnt, + affinities: build_index_affinity_string(idx, &parent_tbl), + }); + } + start + }; + + // FOUND: ok; NOT FOUND: violation path + index_probe( + program, + icur, + probe, + ncols, + |_p| Ok(()), + |p| { + emit_fk_violation(p, &fk_ref.fk)?; + Ok(()) + }, + )?; + program.emit_insn(Insn::Goto { target_pc: fk_ok }); + } + + // Skip label for NEW tuple NULL short-circuit + program.preassign_label_to_next_insn(fk_ok); + } + + Ok(()) +} + +/// Prevent deleting a parent row that is still referenced by any child. +/// For each incoming FK referencing `parent_table_name`: +/// 1. Build the parent key vector from the current parent row (FK parent-column order, +/// or the table's PK columns when the FK omits parent columns). +/// 2. Look for referencing child rows: +/// - Prefer an exact child index on (child_columns...). If found, probe the index. +/// - Otherwise scan the child table. For self-referential FKs, exclude the current rowid. +/// 3. If a referencing child exists: +/// - Immediate FK: HALT with SQLITE_CONSTRAINT_FOREIGNKEY +/// - Deferred FK: FkCounter +1 +pub fn emit_fk_delete_parent_existence_checks( + program: &mut ProgramBuilder, + resolver: &Resolver, + parent_table_name: &str, + parent_cursor_id: usize, + parent_rowid_reg: usize, +) -> Result<()> { + let parent_bt = resolver + .schema + .get_btree_table(parent_table_name) + .ok_or_else(|| crate::LimboError::InternalError("parent not btree".into()))?; + + for fk_ref in resolver + .schema + .resolved_fks_referencing(parent_table_name)? + { + let is_self_ref = fk_ref + .child_table + .name + .eq_ignore_ascii_case(parent_table_name); + + // Build parent key in FK's parent-column order (or table PK columns if unspecified). + let parent_cols: Vec = if fk_ref.fk.parent_columns.is_empty() { + parent_bt + .primary_key_columns + .iter() + .map(|(n, _)| n.clone()) + .collect() + } else { + fk_ref.fk.parent_columns.clone() + }; + let ncols = parent_cols.len(); + + let parent_key_start = program.alloc_registers(ncols); + build_parent_key( + program, + &parent_bt, + &parent_cols, + parent_cursor_id, + parent_rowid_reg, + parent_key_start, + )?; + + // Try an exact child index on (child_columns...) if available and not self-ref + let child_cols = &fk_ref.fk.child_columns; + let child_idx = if !is_self_ref { + resolver + .schema + .get_indices(&fk_ref.child_table.name) + .find(|idx| { + idx.columns.len() == child_cols.len() + && idx + .columns + .iter() + .zip(child_cols.iter()) + .all(|(ic, cc)| ic.name.eq_ignore_ascii_case(cc)) + }) + } else { + None + }; + + if let Some(idx) = child_idx { + // Index probe: FOUND => violation; NOT FOUND => ok. + let icur = open_read_index(program, idx); + let probe = + copy_with_affinity(program, parent_key_start, ncols, idx, &fk_ref.child_table); + + index_probe( + program, + icur, + probe, + ncols, + |p| { + emit_fk_violation(p, &fk_ref.fk)?; + Ok(()) + }, + |_p| Ok(()), + )?; + } else { + // Table scan fallback; for self-ref, exclude the same parent row by rowid. + table_scan_match_any( + program, + &fk_ref.child_table, + child_cols, + parent_key_start, + if is_self_ref { + Some(parent_rowid_reg) + } else { + None + }, + |p| { + emit_fk_violation(p, &fk_ref.fk)?; + Ok(()) + }, + )?; + } + } + Ok(()) +} diff --git a/core/translate/insert.rs b/core/translate/insert.rs index f6cfa4b88..7e4900f2c 100644 --- a/core/translate/insert.rs +++ b/core/translate/insert.rs @@ -8,7 +8,7 @@ use turso_parser::ast::{ use crate::error::{ SQLITE_CONSTRAINT_NOTNULL, SQLITE_CONSTRAINT_PRIMARYKEY, SQLITE_CONSTRAINT_UNIQUE, }; -use crate::schema::{self, Affinity, Index, Table}; +use crate::schema::{self, Affinity, BTreeTable, Index, ResolvedFkRef, Table}; use crate::translate::emitter::{ emit_cdc_insns, emit_cdc_patch_record, prepare_cdc_if_necessary, OperationMode, }; @@ -16,6 +16,10 @@ use crate::translate::expr::{ bind_and_rewrite_expr, emit_returning_results, process_returning_clause, walk_expr_mut, BindingBehavior, ReturningValueRegisters, WalkControl, }; +use crate::translate::fkeys::{ + build_index_affinity_string, emit_fk_scope_if_needed, emit_fk_violation, index_probe, + open_read_index, open_read_table, +}; use crate::translate::plan::TableReferences; use crate::translate::planner::ROWID_STRS; use crate::translate::upsert::{ @@ -134,11 +138,6 @@ pub fn translate_insert( if !btree_table.has_rowid { crate::bail_parse_error!("INSERT into WITHOUT ROWID table is not supported"); } - let has_child_fks = fk_enabled && !btree_table.foreign_keys.is_empty(); - let has_parent_fks = fk_enabled - && resolver - .schema - .any_resolved_fks_referencing(table_name.as_str()); let root_page = btree_table.root_page; @@ -242,14 +241,11 @@ pub fn translate_insert( connection, )?; - if has_child_fks || has_parent_fks { - program.emit_insn(Insn::FkCounter { - increment_value: 1, - check_abort: false, - is_scope: true, - }); - } - + let has_fks = if fk_enabled { + emit_fk_scope_if_needed(&mut program, resolver, table_name.as_str(), true)? + } else { + false + }; let mut yield_reg_opt = None; let mut temp_table_ctx = None; let (num_values, cursor_id) = match body { @@ -274,7 +270,6 @@ pub fn translate_insert( let query_destination = QueryDestination::CoroutineYield { yield_reg, - // keep implementation_start as halt_label (producer internals) coroutine_implementation_start: halt_label, }; program.incr_nesting(); @@ -1043,13 +1038,14 @@ pub fn translate_insert( } } } - if has_child_fks || has_parent_fks { - emit_fk_checks_for_insert( + if has_fks { + // Child-side check must run before Insert (may HALT or increment deferred counter) + emit_fk_child_insert_checks( &mut program, resolver, - &insertion, - table_name.as_str(), - !inserting_multiple_rows, + &btree_table, + insertion.first_col_register(), + insertion.key_register(), )?; } @@ -1061,6 +1057,11 @@ pub fn translate_insert( table_name: table_name.to_string(), }); + if has_fks { + // After the row is actually present, repair deferred counters for children referencing this NEW parent key. + emit_parent_side_fk_decrement_on_insert(&mut program, resolver, &btree_table, &insertion)?; + } + if let Some((seq_cursor_id, r_seq, r_seq_rowid, table_name_reg)) = autoincrement_meta { let no_update_needed_label = program.allocate_label(); program.emit_insn(Insn::Le { @@ -1151,6 +1152,7 @@ pub fn translate_insert( &mut result_columns, cdc_table.as_ref().map(|c| c.0), row_done_label, + connection, )?; } else { // UpsertDo::Nothing case @@ -1198,13 +1200,8 @@ pub fn translate_insert( } program.preassign_label_to_next_insn(stmt_epilogue); - if has_child_fks || has_parent_fks { - // close FK scope and surface deferred violations - program.emit_insn(Insn::FkCounter { - increment_value: -1, - check_abort: true, - is_scope: true, - }); + if has_fks { + emit_fk_scope_if_needed(&mut program, resolver, table_name.as_str(), false)?; } program.resolve_label(halt_label, program.offset()); @@ -1900,38 +1897,29 @@ fn emit_update_sqlite_sequence( Ok(()) } -/// Emit child->parent foreign key checks for an INSERT, for the current row -fn emit_fk_checks_for_insert( +/// Child-side FK checks for INSERT of a single row: +/// For each outgoing FK on `child_tbl`, if the NEW tuple's FK columns are all non-NULL, +/// verify that the referenced parent key exists. +pub fn emit_fk_child_insert_checks( program: &mut ProgramBuilder, resolver: &Resolver, - insertion: &Insertion, - table_name: &str, - single_row_insert: bool, -) -> Result<()> { - let after_all = program.allocate_label(); - program.emit_insn(Insn::FkIfZero { - target_pc: after_all, - if_zero: true, - }); + child_tbl: &BTreeTable, + new_start_reg: usize, + new_rowid_reg: usize, +) -> crate::Result<()> { + for fk_ref in resolver.schema.resolved_fks_for_child(&child_tbl.name)? { + let ncols = fk_ref.child_cols.len(); + let is_self_ref = fk_ref.fk.parent_table.eq_ignore_ascii_case(&child_tbl.name); - // Iterate child FKs declared on this table - for fk_ref in resolver.schema.resolved_fks_for_child(table_name) { - let parent_tbl = resolver - .schema - .get_btree_table(&fk_ref.fk.parent_table) - .expect("parent table"); - let num_child_cols = fk_ref.child_cols.len(); - let is_self_single = - table_name.eq_ignore_ascii_case(&fk_ref.fk.parent_table) && single_row_insert; - // if any child FK value is NULL, this row doesn't reference the parent. + // Short-circuit if any NEW component is NULL let fk_ok = program.allocate_label(); - for &pos_in_child in fk_ref.child_pos.iter() { - // Map INSERT image register for that column - let src = insertion - .col_mappings - .get(pos_in_child) - .expect("col must be present") - .register; + for cname in &fk_ref.child_cols { + let (i, col) = child_tbl.get_column(cname).unwrap(); + let src = if col.is_rowid_alias { + new_rowid_reg + } else { + new_start_reg + i + }; program.emit_insn(Insn::IsNull { reg: src, target_pc: fk_ok, @@ -1939,36 +1927,29 @@ fn emit_fk_checks_for_insert( } if fk_ref.parent_uses_rowid { - // Parent is rowid/alias: single-reg probe - let pcur = program.alloc_cursor_id(CursorType::BTreeTable(parent_tbl.clone())); - program.emit_insn(Insn::OpenRead { - cursor_id: pcur, - root_page: parent_tbl.root_page, - db: 0, - }); - let rowid_pos = 0; // guaranteed if parent_uses_rowid - let src = insertion - .get_col_mapping_by_name(fk_ref.child_cols[rowid_pos].as_str()) - .unwrap() - .register; - let violation = program.allocate_label(); + let parent_tbl = resolver + .schema + .get_btree_table(&fk_ref.fk.parent_table) + .expect("parent btree"); + let pcur = open_read_table(program, &parent_tbl); + + // first child col carries rowid + let (i_child, col_child) = child_tbl.get_column(&fk_ref.child_cols[0]).unwrap(); + let val_reg = if col_child.is_rowid_alias { + new_rowid_reg + } else { + new_start_reg + i_child + }; + let tmp = program.alloc_register(); program.emit_insn(Insn::Copy { - src_reg: src, + src_reg: val_reg, dst_reg: tmp, extra_amount: 0, }); - // coerce to INT (parent rowid affinity) program.emit_insn(Insn::MustBeInt { reg: tmp }); - if is_self_single { - program.emit_insn(Insn::Eq { - lhs: tmp, - rhs: insertion.key_register(), - target_pc: fk_ok, - flags: CmpInsFlags::default(), - collation: None, - }); - } + + let violation = program.allocate_label(); program.emit_insn(Insn::NotExists { cursor: pcur, rowid_reg: tmp, @@ -1980,102 +1961,296 @@ fn emit_fk_checks_for_insert( program.preassign_label_to_next_insn(violation); program.emit_insn(Insn::Close { cursor_id: pcur }); - // Deferred vs immediate - if fk_ref.fk.deferred { + // Self-ref: count (don’t halt). Non-self: standard behavior. + if is_self_ref { program.emit_insn(Insn::FkCounter { increment_value: 1, - check_abort: false, is_scope: false, }); } else { - program.emit_insn(Insn::Halt { - err_code: crate::error::SQLITE_CONSTRAINT_FOREIGNKEY, - description: "FOREIGN KEY constraint failed".to_string(), - }); + emit_fk_violation(program, &fk_ref.fk)?; } - } else if let Some(ix) = &fk_ref.parent_unique_index { - if is_self_single { - let skip_probe = program.allocate_label(); - for (i, &pos_in_child) in fk_ref.child_pos.iter().enumerate() { - let child_reg = insertion.col_mappings.get(pos_in_child).unwrap().register; - let parent_reg = insertion - .get_col_mapping_by_name(fk_ref.parent_cols[i].as_str()) - .unwrap() - .register; - program.emit_insn(Insn::Ne { - lhs: child_reg, - rhs: parent_reg, - target_pc: skip_probe, // any mismatch and we do the normal probe - flags: CmpInsFlags::default().jump_if_null(), - collation: None, + } else { + // Parent by unique index + let parent_tbl = resolver + .schema + .get_btree_table(&fk_ref.fk.parent_table) + .expect("parent btree"); + let idx = fk_ref + .parent_unique_index + .as_ref() + .expect("parent unique index required"); + let icur = open_read_index(program, idx); + + // Build NEW probe from child NEW values; apply parent index affinities + let probe = { + let start = program.alloc_registers(ncols); + for (k, cname) in fk_ref.child_cols.iter().enumerate() { + let (i, col) = child_tbl.get_column(cname).unwrap(); + program.emit_insn(Insn::Copy { + src_reg: if col.is_rowid_alias { + new_rowid_reg + } else { + new_start_reg + i + }, + dst_reg: start + k, + extra_amount: 0, }); } - // all matched, OK - program.emit_insn(Insn::Goto { target_pc: fk_ok }); - program.preassign_label_to_next_insn(skip_probe); - } + if let Some(cnt) = NonZeroUsize::new(ncols) { + program.emit_insn(Insn::Affinity { + start_reg: start, + count: cnt, + affinities: build_index_affinity_string(idx, &parent_tbl), + }); + } + start + }; + index_probe( + program, + icur, + probe, + ncols, + |_p| Ok(()), + |p| { + if is_self_ref { + p.emit_insn(Insn::FkCounter { + increment_value: 1, + is_scope: false, + }); + } else { + emit_fk_violation(p, &fk_ref.fk)?; + } + Ok(()) + }, + )?; + program.emit_insn(Insn::Goto { target_pc: fk_ok }); + } - // Parent has a UNIQUE index exactly on parent_cols: use Found against that index - let icur = program.alloc_cursor_id(CursorType::BTreeIndex(ix.clone())); - program.emit_insn(Insn::OpenRead { - cursor_id: icur, - root_page: ix.root_page, - db: 0, - }); + program.preassign_label_to_next_insn(fk_ok); + } + Ok(()) +} - // Build probe (child values order == parent index order by construction) - let probe_start = program.alloc_registers(num_child_cols); - for (i, &pos_in_child) in fk_ref.child_pos.iter().enumerate() { - let src = insertion.col_mappings.get(pos_in_child).unwrap().register; +/// Build NEW parent key image in FK parent-column order into a contiguous register block. +/// Handles 3 shapes: +/// - parent_uses_rowid: single "rowid" component +/// - explicit fk.parent_columns +/// - fk.parent_columns empty => use parent's declared PK columns (order-preserving) +fn build_parent_key_image_for_insert( + program: &mut ProgramBuilder, + parent_table: &BTreeTable, + pref: &ResolvedFkRef, + insertion: &Insertion, +) -> crate::Result<(usize, usize)> { + // Decide column list + let parent_cols: Vec = if pref.parent_uses_rowid { + vec!["rowid".to_string()] + } else if !pref.fk.parent_columns.is_empty() { + pref.fk.parent_columns.clone() + } else { + // fall back to the declared PK of the parent table, in schema order + parent_table + .primary_key_columns + .iter() + .map(|(n, _)| n.clone()) + .collect() + }; + + let ncols = parent_cols.len(); + let start = program.alloc_registers(ncols); + // Copy from the would-be parent insertion + for (i, pname) in parent_cols.iter().enumerate() { + let src = if pname.eq_ignore_ascii_case("rowid") { + insertion.key_register() + } else { + // For rowid-alias parents, get_col_mapping_by_name will return the key mapping, + // not the NULL placeholder in col_mappings. + insertion + .get_col_mapping_by_name(pname) + .ok_or_else(|| { + crate::LimboError::PlanningError(format!( + "Column '{}' not present in INSERT image for parent {}", + pname, parent_table.name + )) + })? + .register + }; + program.emit_insn(Insn::Copy { + src_reg: src, + dst_reg: start + i, + extra_amount: 0, + }); + } + + // Apply affinities of the parent columns (or integer for rowid) + let aff: String = if pref.parent_uses_rowid { + "i".to_string() + } else { + parent_cols + .iter() + .map(|name| { + let (_, col) = parent_table.get_column(name).ok_or_else(|| { + crate::LimboError::InternalError(format!("parent col {name} missing")) + })?; + Ok::<_, crate::LimboError>(col.affinity().aff_mask()) + }) + .collect::>()? + }; + if let Some(count) = NonZeroUsize::new(ncols) { + program.emit_insn(Insn::Affinity { + start_reg: start, + count, + affinities: aff, + }); + } + + Ok((start, ncols)) +} + +/// Parent-side: when inserting into the parent, decrement the counter +/// if any child rows reference the NEW parent key. +/// We *always* do this for deferred FKs, and we *also* do it for +/// self-referential FKs (even if immediate) because the insert can +/// “repair” a prior child-insert count recorded earlier in the same statement. +pub fn emit_parent_side_fk_decrement_on_insert( + program: &mut ProgramBuilder, + resolver: &Resolver, + parent_table: &BTreeTable, + insertion: &Insertion, +) -> crate::Result<()> { + for pref in resolver + .schema + .resolved_fks_referencing(&parent_table.name)? + { + let is_self_ref = pref + .child_table + .name + .eq_ignore_ascii_case(&parent_table.name); + // Skip only when it cannot repair anything: non-deferred and not self-ref. + if !pref.fk.deferred && !is_self_ref { + continue; + } + let (new_pk_start, n_cols) = + build_parent_key_image_for_insert(program, parent_table, &pref, insertion)?; + + let child_tbl = &pref.child_table; + let child_cols = &pref.fk.child_columns; + let idx = resolver.schema.get_indices(&child_tbl.name).find(|ix| { + ix.columns.len() == child_cols.len() + && ix + .columns + .iter() + .zip(child_cols.iter()) + .all(|(ic, cc)| ic.name.eq_ignore_ascii_case(cc)) + }); + + if let Some(ix) = idx { + let icur = open_read_index(program, ix); + // Copy key into probe regs and apply child-index affinities + let probe_start = program.alloc_registers(n_cols); + for i in 0..n_cols { program.emit_insn(Insn::Copy { - src_reg: src, + src_reg: new_pk_start + i, dst_reg: probe_start + i, extra_amount: 0, }); } - - let aff: String = ix - .columns - .iter() - .map(|c| parent_tbl.columns[c.pos_in_table].affinity().aff_mask()) - .collect(); - program.emit_insn(Insn::Affinity { - start_reg: probe_start, - count: std::num::NonZeroUsize::new(num_child_cols).unwrap(), - affinities: aff, - }); + if let Some(count) = NonZeroUsize::new(n_cols) { + program.emit_insn(Insn::Affinity { + start_reg: probe_start, + count, + affinities: build_index_affinity_string(ix, child_tbl), + }); + } let found = program.allocate_label(); program.emit_insn(Insn::Found { cursor_id: icur, target_pc: found, record_reg: probe_start, - num_regs: num_child_cols, + num_regs: n_cols, }); - // Not found: violation + // Not found => nothing to decrement program.emit_insn(Insn::Close { cursor_id: icur }); - if fk_ref.fk.deferred { - program.emit_insn(Insn::FkCounter { - increment_value: 1, - check_abort: false, - is_scope: false, + let skip = program.allocate_label(); + program.emit_insn(Insn::Goto { target_pc: skip }); + + // Found => guarded decrement + program.resolve_label(found, program.offset()); + program.emit_insn(Insn::Close { cursor_id: icur }); + program.emit_insn(Insn::FkIfZero { + is_scope: false, + target_pc: skip, + }); + program.emit_insn(Insn::FkCounter { + increment_value: -1, + is_scope: false, + }); + program.resolve_label(skip, program.offset()); + } else { + // fallback scan :( + let ccur = open_read_table(program, child_tbl); + let done = program.allocate_label(); + program.emit_insn(Insn::Rewind { + cursor_id: ccur, + pc_if_empty: done, + }); + let loop_top = program.allocate_label(); + let next_row = program.allocate_label(); + program.resolve_label(loop_top, program.offset()); + + for (i, child_name) in child_cols.iter().enumerate() { + let (pos, _) = child_tbl.get_column(child_name).ok_or_else(|| { + crate::LimboError::InternalError(format!("child col {child_name} missing")) + })?; + let tmp = program.alloc_register(); + program.emit_insn(Insn::Column { + cursor_id: ccur, + column: pos, + dest: tmp, + default: None, }); - } else { - program.emit_insn(Insn::Halt { - err_code: crate::error::SQLITE_CONSTRAINT_FOREIGNKEY, - description: "FOREIGN KEY constraint failed".to_string(), + + program.emit_insn(Insn::IsNull { + reg: tmp, + target_pc: next_row, }); + + let cont = program.allocate_label(); + program.emit_insn(Insn::Eq { + lhs: tmp, + rhs: new_pk_start + i, + target_pc: cont, + flags: CmpInsFlags::default().jump_if_null(), + collation: Some(super::collate::CollationSeq::Binary), + }); + program.emit_insn(Insn::Goto { + target_pc: next_row, + }); + program.resolve_label(cont, program.offset()); } - program.emit_insn(Insn::Goto { target_pc: fk_ok }); - // Found OK - program.preassign_label_to_next_insn(found); - program.emit_insn(Insn::Close { cursor_id: icur }); + + // Matched one child row -> guarded decrement + program.emit_insn(Insn::FkIfZero { + is_scope: false, + target_pc: next_row, + }); + program.emit_insn(Insn::FkCounter { + is_scope: false, + increment_value: -1, + }); + + program.resolve_label(next_row, program.offset()); + program.emit_insn(Insn::Next { + cursor_id: ccur, + pc_if_next: loop_top, + }); + + program.resolve_label(done, program.offset()); + program.emit_insn(Insn::Close { cursor_id: ccur }); } - - program.preassign_label_to_next_insn(fk_ok); } - - program.preassign_label_to_next_insn(after_all); Ok(()) } diff --git a/core/translate/mod.rs b/core/translate/mod.rs index 690ad7c47..d51d89dea 100644 --- a/core/translate/mod.rs +++ b/core/translate/mod.rs @@ -17,6 +17,7 @@ pub(crate) mod delete; pub(crate) mod display; pub(crate) mod emitter; pub(crate) mod expr; +pub(crate) mod fkeys; pub(crate) mod group_by; pub(crate) mod index; pub(crate) mod insert; diff --git a/core/translate/upsert.rs b/core/translate/upsert.rs index 2ae07f961..868f3a933 100644 --- a/core/translate/upsert.rs +++ b/core/translate/upsert.rs @@ -5,11 +5,9 @@ use std::{collections::HashMap, sync::Arc}; use turso_parser::ast::{self, Upsert}; use crate::error::SQLITE_CONSTRAINT_PRIMARYKEY; -use crate::translate::emitter::{ - emit_fk_child_existence_checks, emit_fk_parent_existence_checks, - emit_fk_parent_pk_change_counters, -}; +use crate::schema::ROWID_SENTINEL; use crate::translate::expr::{walk_expr, WalkControl}; +use crate::translate::fkeys::{emit_fk_child_update_counters, emit_parent_pk_change_checks}; use crate::translate::insert::format_unique_violation_desc; use crate::translate::planner::ROWID_STRS; use crate::vdbe::insn::CmpInsFlags; @@ -471,176 +469,49 @@ pub fn emit_upsert( } let (changed_cols, rowid_changed) = collect_changed_cols(table, set_pairs); + let rowid_alias_idx = table.columns().iter().position(|c| c.is_rowid_alias); + let has_direct_rowid_update = set_pairs + .iter() + .any(|(idx, _)| *idx == rowid_alias_idx.unwrap_or(ROWID_SENTINEL)); + let has_user_provided_rowid = if let Some(i) = rowid_alias_idx { + set_pairs.iter().any(|(idx, _)| *idx == i) || has_direct_rowid_update + } else { + has_direct_rowid_update + }; + let rowid_set_clause_reg = if has_user_provided_rowid { + Some(new_rowid_reg.unwrap_or(conflict_rowid_reg)) + } else { + None + }; if let Some(bt) = table.btree() { if connection.foreign_keys_enabled() { let rowid_new_reg = new_rowid_reg.unwrap_or(conflict_rowid_reg); // Child-side checks if resolver.schema.has_child_fks(bt.name.as_str()) { - emit_fk_child_existence_checks( + emit_fk_child_update_counters( program, resolver, &bt, table.get_name(), + tbl_cursor_id, new_start, rowid_new_reg, &changed_cols, )?; } - - // Parent-side checks only if any incoming FK could care - if resolver - .schema - .any_resolved_fks_referencing(table.get_name()) - { - // if parent key can't change, skip - let updated_parent_positions: HashSet = - set_pairs.iter().map(|(i, _)| *i).collect(); - let incoming = resolver.schema.resolved_fks_referencing(table.get_name()); - let parent_key_may_change = incoming - .iter() - .any(|r| r.parent_key_may_change(&updated_parent_positions, &bt)); - - if parent_key_may_change { - let skip_parent_fk = program.allocate_label(); - let pk_len = bt.primary_key_columns.len(); - - match pk_len { - 0 => { - // implicit rowid - program.emit_insn(Insn::Eq { - lhs: rowid_new_reg, - rhs: conflict_rowid_reg, - target_pc: skip_parent_fk, - flags: CmpInsFlags::default(), - collation: program.curr_collation(), - }); - emit_fk_parent_existence_checks( - program, - resolver, - table.get_name(), - tbl_cursor_id, - conflict_rowid_reg, - )?; - program.preassign_label_to_next_insn(skip_parent_fk); - } - 1 => { - // single-col declared PK - let (pk_name, _) = &bt.primary_key_columns[0]; - let (pos, col) = bt.get_column(pk_name).unwrap(); - - let old_reg = program.alloc_register(); - if col.is_rowid_alias { - program.emit_insn(Insn::RowId { - cursor_id: tbl_cursor_id, - dest: old_reg, - }); - } else { - program.emit_insn(Insn::Column { - cursor_id: tbl_cursor_id, - column: pos, - dest: old_reg, - default: None, - }); - } - let new_reg = new_start + pos; - - let skip = program.allocate_label(); - program.emit_insn(Insn::Eq { - lhs: old_reg, - rhs: new_reg, - target_pc: skip, - flags: CmpInsFlags::default(), - collation: program.curr_collation(), - }); - emit_fk_parent_existence_checks( - program, - resolver, - table.get_name(), - tbl_cursor_id, - conflict_rowid_reg, - )?; - program.preassign_label_to_next_insn(skip); - } - _ => { - // composite PK: build OLD/NEW vectors and do the 2-pass counter logic - let old_pk_start = program.alloc_registers(pk_len); - for (i, (pk_name, _)) in bt.primary_key_columns.iter().enumerate() { - let (pos, col) = bt.get_column(pk_name).unwrap(); - if col.is_rowid_alias { - // old rowid (UPSERT target) == conflict_rowid_reg - program.emit_insn(Insn::Copy { - src_reg: conflict_rowid_reg, - dst_reg: old_pk_start + i, - extra_amount: 0, - }); - } else { - program.emit_insn(Insn::Column { - cursor_id: tbl_cursor_id, - column: pos, - dest: old_pk_start + i, - default: None, - }); - } - } - - let new_pk_start = program.alloc_registers(pk_len); - for (i, (pk_name, _)) in bt.primary_key_columns.iter().enumerate() { - let (pos, col) = bt.get_column(pk_name).unwrap(); - let src = if col.is_rowid_alias { - rowid_new_reg - } else { - new_start + pos - }; - program.emit_insn(Insn::Copy { - src_reg: src, - dst_reg: new_pk_start + i, - extra_amount: 0, - }); - } - - // Compare OLD vs NEW, if all equal then skip - let skip = program.allocate_label(); - let changed = program.allocate_label(); - for i in 0..pk_len { - if i == pk_len - 1 { - program.emit_insn(Insn::Eq { - lhs: old_pk_start + i, - rhs: new_pk_start + i, - target_pc: skip, - flags: CmpInsFlags::default(), - collation: program.curr_collation(), - }); - program.emit_insn(Insn::Goto { target_pc: changed }); - } else { - let next = program.allocate_label(); - program.emit_insn(Insn::Eq { - lhs: old_pk_start + i, - rhs: new_pk_start + i, - target_pc: next, - flags: CmpInsFlags::default(), - collation: program.curr_collation(), - }); - program.emit_insn(Insn::Goto { target_pc: changed }); - program.preassign_label_to_next_insn(next); - } - } - - program.preassign_label_to_next_insn(changed); - emit_fk_parent_pk_change_counters( - program, - &incoming, - resolver, - old_pk_start, - new_pk_start, - pk_len, - )?; - program.preassign_label_to_next_insn(skip); - } - } - } - } + emit_parent_pk_change_checks( + program, + resolver, + &bt, + tbl_cursor_id, + conflict_rowid_reg, + new_start, + new_rowid_reg.unwrap_or(conflict_rowid_reg), + rowid_set_clause_reg, + set_pairs, + )?; } } diff --git a/core/vdbe/builder.rs b/core/vdbe/builder.rs index 3d1a333ec..70e98eb00 100644 --- a/core/vdbe/builder.rs +++ b/core/vdbe/builder.rs @@ -792,6 +792,9 @@ impl ProgramBuilder { Insn::NotFound { target_pc, .. } => { resolve(target_pc, "NotFound"); } + Insn::FkIfZero { target_pc, .. } => { + resolve(target_pc, "FkIfZero"); + } _ => {} } } diff --git a/core/vdbe/execute.rs b/core/vdbe/execute.rs index 40642b87d..09e112e0e 100644 --- a/core/vdbe/execute.rs +++ b/core/vdbe/execute.rs @@ -2169,6 +2169,17 @@ pub fn halt( let auto_commit = program.connection.auto_commit.load(Ordering::SeqCst); tracing::trace!("halt(auto_commit={})", auto_commit); if auto_commit { + if program.connection.foreign_keys_enabled() + && program + .connection + .fk_deferred_violations + .swap(0, Ordering::AcqRel) + > 0 + { + return Err(LimboError::Constraint( + "foreign key constraint failed".to_string(), + )); + } program .commit_txn(pager.clone(), state, mv_store, false) .map(Into::into) @@ -2263,12 +2274,13 @@ pub fn op_transaction_inner( if write && conn.db.open_flags.get().contains(OpenFlags::ReadOnly) { return Err(LimboError::ReadOnly); } - // 1. We try to upgrade current version let current_state = conn.get_tx_state(); - let (new_transaction_state, updated) = if conn.is_nested_stmt.load(Ordering::SeqCst) + let (new_transaction_state, updated, should_clear_deferred_violations) = if conn + .is_nested_stmt + .load(Ordering::SeqCst) { - (current_state, false) + (current_state, false, false) } else { match (current_state, write) { // pending state means that we tried beginning a tx and the method returned IO. @@ -2283,30 +2295,36 @@ pub fn op_transaction_inner( schema_did_change: false, }, true, + true, ) } (TransactionState::Write { schema_did_change }, true) => { - (TransactionState::Write { schema_did_change }, false) + (TransactionState::Write { schema_did_change }, false, false) } (TransactionState::Write { schema_did_change }, false) => { - (TransactionState::Write { schema_did_change }, false) + (TransactionState::Write { schema_did_change }, false, false) } (TransactionState::Read, true) => ( TransactionState::Write { schema_did_change: false, }, true, + true, ), - (TransactionState::Read, false) => (TransactionState::Read, false), + (TransactionState::Read, false) => (TransactionState::Read, false, false), (TransactionState::None, true) => ( TransactionState::Write { schema_did_change: false, }, true, + true, ), - (TransactionState::None, false) => (TransactionState::Read, true), + (TransactionState::None, false) => (TransactionState::Read, true, false), } }; + if should_clear_deferred_violations { + conn.fk_deferred_violations.store(0, Ordering::Release); + } // 2. Start transaction if needed if let Some(mv_store) = &mv_store { @@ -2383,8 +2401,8 @@ pub fn op_transaction_inner( return Err(LimboError::Busy); } if let IOResult::IO(io) = begin_w_tx_res? { - // set the transaction state to pending so we don't have to // end the read transaction. + // set the transaction state to pending so we don't have to program .connection .set_tx_state(TransactionState::PendingUpgrade); @@ -2448,15 +2466,20 @@ pub fn op_auto_commit( }, insn ); - let conn = program.connection.clone(); if matches!(state.commit_state, CommitState::Committing) { return program .commit_txn(pager.clone(), state, mv_store, *rollback) .map(Into::into); } + let conn = program.connection.clone(); if *auto_commit != conn.auto_commit.load(Ordering::SeqCst) { if *rollback { + program // reset deferred fk violations on ROLLBACK + .connection + .fk_deferred_violations + .store(0, Ordering::Release); + // TODO(pere): add rollback I/O logic once we implement rollback journal if let Some(mv_store) = mv_store { if let Some(tx_id) = conn.get_mv_tx_id() { @@ -2468,6 +2491,15 @@ pub fn op_auto_commit( conn.set_tx_state(TransactionState::None); conn.auto_commit.store(true, Ordering::SeqCst); } else { + if conn.foreign_keys_enabled() { + let violations = conn.fk_deferred_violations.swap(0, Ordering::AcqRel); + if violations > 0 { + // Fail the commit + return Err(LimboError::Constraint( + "FOREIGN KEY constraint failed".into(), + )); + } + } conn.auto_commit.store(*auto_commit, Ordering::SeqCst); } } else { @@ -2494,6 +2526,15 @@ pub fn op_auto_commit( )); } } + if conn.foreign_keys_enabled() { + let violations = conn.fk_deferred_violations.swap(0, Ordering::AcqRel); + if violations > 0 { + // Fail the commit + return Err(LimboError::Constraint( + "FOREIGN KEY constraint failed".into(), + )); + } + } } program @@ -8289,35 +8330,18 @@ pub fn op_fk_counter( load_insn!( FkCounter { increment_value, - check_abort, is_scope, }, insn ); if *is_scope { - // Adjust FK scope depth state.fk_scope_counter = state.fk_scope_counter.saturating_add(*increment_value); - - // raise if there were deferred violations in this statement. - if *check_abort { - if state.fk_scope_counter < 0 { - return Err(LimboError::Constraint( - "FOREIGN KEY constraint failed".into(), - )); - } - if state.fk_scope_counter == 0 && state.fk_deferred_violations > 0 { - // Clear violations for safety, a new statement will re-open scope. - state.fk_deferred_violations = 0; - return Err(LimboError::Constraint( - "FOREIGN KEY constraint failed".into(), - )); - } - } } else { - // Adjust deferred violations counter - state.fk_deferred_violations = state + // Transaction-level counter: add/subtract for deferred FKs. + program + .connection .fk_deferred_violations - .saturating_add(*increment_value); + .fetch_add(*increment_value, Ordering::AcqRel); } state.pc += 1; @@ -8331,29 +8355,37 @@ pub fn op_fk_if_zero( _pager: &Arc, _mv_store: Option<&Arc>, ) -> Result { - load_insn!(FkIfZero { target_pc, if_zero }, insn); + load_insn!( + FkIfZero { + is_scope, + target_pc, + }, + insn + ); let fk_enabled = program.connection.foreign_keys_enabled(); // Jump if any: // Foreign keys are disabled globally // p1 is true AND deferred constraint counter is zero // p1 is false AND deferred constraint counter is non-zero - let scope_zero = state.fk_scope_counter == 0; - - let should_jump = if !fk_enabled { - true - } else if *if_zero { - scope_zero + if !fk_enabled { + state.pc = target_pc.as_offset_int(); + return Ok(InsnFunctionStepResult::Step); + } + let v = if !*is_scope { + program + .connection + .fk_deferred_violations + .load(Ordering::Acquire) } else { - !scope_zero + state.fk_scope_counter }; - if should_jump { - state.pc = target_pc.as_offset_int(); + state.pc = if v == 0 { + target_pc.as_offset_int() } else { - state.pc += 1; - } - + state.pc + 1 + }; Ok(InsnFunctionStepResult::Step) } diff --git a/core/vdbe/explain.rs b/core/vdbe/explain.rs index 3f99fe809..ca5b74ef3 100644 --- a/core/vdbe/explain.rs +++ b/core/vdbe/explain.rs @@ -1804,19 +1804,19 @@ pub fn insn_to_row( 0, String::new(), ), - Insn::FkCounter{check_abort, increment_value, is_scope } => ( + Insn::FkCounter{increment_value, is_scope } => ( "FkCounter", - *check_abort as i32, *increment_value as i32, *is_scope as i32, + 0, Value::build_text(""), 0, String::new(), ), - Insn::FkIfZero{target_pc, if_zero } => ( + Insn::FkIfZero{target_pc, is_scope } => ( "FkIfZero", target_pc.as_debug_int(), - *if_zero as i32, + *is_scope as i32, 0, Value::build_text(""), 0, diff --git a/core/vdbe/insn.rs b/core/vdbe/insn.rs index 917038b80..9a5bab21c 100644 --- a/core/vdbe/insn.rs +++ b/core/vdbe/insn.rs @@ -1173,7 +1173,6 @@ pub enum Insn { // If P1 is non-zero, the database constraint counter is incremented (deferred foreign key constraints). // Otherwise, if P1 is zero, the statement counter is incremented (immediate foreign key constraints). FkCounter { - check_abort: bool, increment_value: isize, is_scope: bool, }, @@ -1181,7 +1180,7 @@ pub enum Insn { // If P1 is non-zero, then the jump is taken if the database constraint-counter is zero (the one that counts deferred constraint violations). // If P1 is zero, the jump is taken if the statement constraint-counter is zero (immediate foreign key constraint violations). FkIfZero { - if_zero: bool, + is_scope: bool, target_pc: BranchOffset, }, } diff --git a/core/vdbe/mod.rs b/core/vdbe/mod.rs index d192be864..db633c2f1 100644 --- a/core/vdbe/mod.rs +++ b/core/vdbe/mod.rs @@ -832,7 +832,6 @@ impl Program { // Reset state for next use program_state.view_delta_state = ViewDeltaCommitState::NotStarted; - if self.connection.get_tx_state() == TransactionState::None { // No need to do any work here if not in tx. Current MVCC logic doesn't work with this assumption, // hence the mv_store.is_none() check. @@ -915,6 +914,9 @@ impl Program { self.connection .set_changes(self.n_change.load(Ordering::SeqCst)); } + if connection.foreign_keys_enabled() { + connection.clear_deferred_foreign_key_violations(); + } Ok(IOResult::Done(())) } } diff --git a/testing/foreign_keys.test b/testing/foreign_keys.test index 78e8498f2..8934292fd 100644 --- a/testing/foreign_keys.test +++ b/testing/foreign_keys.test @@ -124,16 +124,12 @@ do_execsql_test_in_memory_any_error fk-composite-unique-missing { INSERT INTO child VALUES (2,'A','X'); -- no ('A','X') in parent } -# SQLite doesnt let you name a foreign key constraint 'rowid' explicitly... -# well it does.. but it throws a parse error only when you try to insert into the table -_- -# We will throw a parse error when you create the table instead, because that is -# obviously the only sane thing to do do_execsql_test_in_memory_any_error fk-rowid-alias-parent { PRAGMA foreign_keys=ON; CREATE TABLE t(id INTEGER PRIMARY KEY, a TEXT); - CREATE TABLE c(cid INTEGER PRIMARY KEY, rid REFERENCES t(rowid)); -- we error here + CREATE TABLE c(cid INTEGER PRIMARY KEY, rid REFERENCES t(rowid)); INSERT INTO t VALUES (100,'x'); - INSERT INTO c VALUES (1, 100); - sqlite errors here + INSERT INTO c VALUES (1, 100); } do_execsql_test_in_memory_any_error fk-rowid-alias-parent-missing { @@ -399,3 +395,712 @@ do_execsql_test_in_memory_any_error fk-self-multirow-one-bad { FOREIGN KEY(rid) REFERENCES t(id)); INSERT INTO t(id,rid) VALUES (1,1),(3,99); -- 99 has no parent -> error } + +# doesnt fail because tx is un-committed +do_execsql_test_on_specific_db {:memory:} fk-deferred-commit-doesnt-fail-early { + PRAGMA foreign_keys=ON; + CREATE TABLE p(id INTEGER PRIMARY KEY); + CREATE TABLE c(id INTEGER PRIMARY KEY, pid INT REFERENCES p(id) DEFERRABLE INITIALLY DEFERRED); + BEGIN; + INSERT INTO c VALUES(1, 99); -- shouldnt fail because we are mid-tx +} {} + +# it should fail here because we actuall COMMIT +do_execsql_test_in_memory_any_error fk-deferred-commit-fails { + PRAGMA foreign_keys=ON; + CREATE TABLE p(id INTEGER PRIMARY KEY); + CREATE TABLE c(id INTEGER PRIMARY KEY, pid INT REFERENCES p(id) DEFERRABLE INITIALLY DEFERRED); + BEGIN; + INSERT INTO c VALUES(1, 99); + COMMIT; +} + + +# If we fix it before COMMIT, COMMIT succeeds +do_execsql_test_on_specific_db {:memory:} fk-deferred-fix-before-commit-succeeds { + PRAGMA foreign_keys=ON; + CREATE TABLE p(id INTEGER PRIMARY KEY); + CREATE TABLE c( + id INTEGER PRIMARY KEY, + pid INT REFERENCES p(id) DEFERRABLE INITIALLY DEFERRED + ); + BEGIN; + INSERT INTO c VALUES(1, 99); -- temporary violation + INSERT INTO p VALUES(99); -- fix parent + COMMIT; + SELECT * FROM p ORDER BY 1; +} {99} + +# ROLLBACK clears deferred state; a new tx can still fail if violation persists +do_execsql_test_on_specific_db {:memory:} fk-deferred-rollback-clears { + PRAGMA foreign_keys=ON; + CREATE TABLE p(id INTEGER PRIMARY KEY); + CREATE TABLE c( + id INTEGER PRIMARY KEY, + pid INT REFERENCES p(id) DEFERRABLE INITIALLY DEFERRED + ); + BEGIN; + INSERT INTO c VALUES(1, 123); + ROLLBACK; + + -- Now start over and *fix* it, COMMIT should pass. + BEGIN; + INSERT INTO p VALUES(123); + INSERT INTO c VALUES(1, 123); + COMMIT; + SELECT * FROM c ORDER BY 1; +} {1|123} + + +do_execsql_test_on_specific_db {:memory:} fk-deferred-insert-parent-fixes-before-commit { + PRAGMA foreign_keys=ON; + CREATE TABLE p(id INTEGER PRIMARY KEY); + CREATE TABLE c( + id INTEGER PRIMARY KEY, + pid INT REFERENCES p(id) DEFERRABLE INITIALLY DEFERRED + ); + BEGIN; + INSERT INTO c VALUES(1, 50); -- violation + INSERT INTO p VALUES(50); -- resolve + COMMIT; + SELECT * FROM c ORDER BY 1; +} {1|50} + +do_execsql_test_on_specific_db {:memory:} fk-deferred-update-fixes-child-before-commit { + PRAGMA foreign_keys=ON; + CREATE TABLE p(id INTEGER PRIMARY KEY); + CREATE TABLE c( + id INTEGER PRIMARY KEY, + pid INT REFERENCES p(id) DEFERRABLE INITIALLY DEFERRED + ); + BEGIN; + INSERT INTO c VALUES(1, 50); -- violation + INSERT INTO p VALUES(32); + UPDATE c SET pid=32 WHERE id=1; -- resolve child + COMMIT; + SELECT * FROM c ORDER BY 1; +} {1|32} + +do_execsql_test_on_specific_db {:memory:} fk-deferred-delete-fixes-child-before-commit { + PRAGMA foreign_keys=ON; + CREATE TABLE p(id INTEGER PRIMARY KEY); + CREATE TABLE c( + id INTEGER PRIMARY KEY, + pid INT REFERENCES p(id) DEFERRABLE INITIALLY DEFERRED + ); + BEGIN; + INSERT INTO c VALUES(1, 50); -- violation + INSERT INTO p VALUES(32); + DELETE FROM c WHERE id=1; -- resolve by deleting child + COMMIT; + SELECT * FROM c ORDER BY 1; +} {} + +do_execsql_test_on_specific_db {:memory:} fk-deferred-update-fixes-parent-before-commit { + PRAGMA foreign_keys=ON; + CREATE TABLE p(id INTEGER PRIMARY KEY); + CREATE TABLE c( + id INTEGER PRIMARY KEY, + pid INT REFERENCES p(id) DEFERRABLE INITIALLY DEFERRED + ); + BEGIN; + INSERT INTO c VALUES(1, 50); -- violation + INSERT INTO p VALUES(32); + UPDATE p SET id=50 WHERE id=32; -- resolve via parent + COMMIT; + SELECT * FROM c ORDER BY 1; +} {1|50} + +# Self-referential: row referencing itself should succeed +do_execsql_test_on_specific_db {:memory:} fk-deferred-self-ref-succeeds { + PRAGMA foreign_keys=ON; + CREATE TABLE t( + id INTEGER PRIMARY KEY, + pid INT REFERENCES t(id) DEFERRABLE INITIALLY DEFERRED + ); + BEGIN; + INSERT INTO t VALUES(1, 1); -- self-match + COMMIT; + SELECT * FROM t ORDER BY 1; +} {1|1} + +# Two-step self-ref: insert invalid, then create parent before COMMIT +do_execsql_test_on_specific_db {:memory:} fk-deferred-self-ref-late-parent { + PRAGMA foreign_keys=ON; + CREATE TABLE t( + id INTEGER PRIMARY KEY, + pid INT REFERENCES t(id) DEFERRABLE INITIALLY DEFERRED + ); + BEGIN; + INSERT INTO t VALUES(2, 3); -- currently invalid + INSERT INTO t VALUES(3, 3); -- now parent exists + COMMIT; + SELECT * FROM t ORDER BY 1; +} {2|3 +3|3} + + +# counter must not be neutralized by later good statements +do_execsql_test_in_memory_any_error fk-deferred-neutralize.1 { + PRAGMA foreign_keys=ON; + CREATE TABLE parent(id INTEGER PRIMARY KEY); + CREATE TABLE parent_comp(a INT NOT NULL, b INT NOT NULL, PRIMARY KEY(a,b)); + CREATE TABLE child_deferred(id INTEGER PRIMARY KEY, pid INT, + FOREIGN KEY(pid) REFERENCES parent(id)); + + CREATE TABLE child_comp_deferred(id INTEGER PRIMARY KEY, ca INT, cb INT, + FOREIGN KEY(ca,cb) REFERENCES parent_comp(a,b)); + INSERT INTO parent_comp VALUES (4,-1); + BEGIN; + INSERT INTO child_deferred VALUES (1, 999); + INSERT INTO child_comp_deferred VALUES (2, 4, -1); + COMMIT; +} + +do_execsql_test_on_specific_db {:memory:} fk-deferred-upsert-late-parent { +PRAGMA foreign_keys=ON; + +CREATE TABLE p(id INTEGER PRIMARY KEY); +CREATE TABLE c( + id INTEGER PRIMARY KEY, + pid INT REFERENCES p(id) DEFERRABLE INITIALLY DEFERRED +); + +BEGIN; + INSERT INTO c VALUES(1, 50); -- deferred violation + INSERT INTO p VALUES(32); -- parent exists, but pid still 50 + INSERT INTO c(id,pid) VALUES(1,32) + ON CONFLICT(id) DO UPDATE SET pid=excluded.pid; -- resolve child via UPSERT +COMMIT; +-- Expect: row is (1,32) and no violations remain +SELECT * FROM c ORDER BY id; +} {1|32} + +do_execsql_test_on_specific_db {:memory:} fk-deferred-upsert-late-child { +PRAGMA foreign_keys=ON; + +CREATE TABLE p( + id INTEGER PRIMARY KEY, + u INT UNIQUE +); +CREATE TABLE c( + id INTEGER PRIMARY KEY, + pid INT REFERENCES p(id) DEFERRABLE INITIALLY DEFERRED +); +BEGIN; + INSERT INTO c VALUES(1, 50); -- deferred violation (no parent 50) + INSERT INTO p VALUES(32, 7); -- parent row with u=7 + -- Trigger DO UPDATE via conflict on p.u, then change the PK id to 50, + -- which satisfies the child reference. + INSERT INTO p(id,u) VALUES(999,7) + ON CONFLICT(u) DO UPDATE SET id=50; +COMMIT; +-- Expect: parent is now (50,7), child (1,50), no violations remain +SELECT p.id, c.id FROM p join c on c.pid = p.id; +} {50|1} + +do_execsql_test_in_memory_any_error fk-deferred-insert-commit-fails { + PRAGMA foreign_keys=ON; + CREATE TABLE p(id INTEGER PRIMARY KEY); + CREATE TABLE c( + id INTEGER PRIMARY KEY, + pid INTEGER REFERENCES p(id) DEFERRABLE INITIALLY DEFERRED + ); + BEGIN; + INSERT INTO c VALUES(1, 99); -- no parent -> deferred violation + COMMIT; -- must fail +} + +do_execsql_test_on_specific_db {:memory:} fk-deferred-insert-parent-fix-before-commit { + PRAGMA foreign_keys=ON; + CREATE TABLE p(id INTEGER PRIMARY KEY); + CREATE TABLE c( + id INTEGER PRIMARY KEY, + pid INTEGER REFERENCES p(id) DEFERRABLE INITIALLY DEFERRED + ); + BEGIN; + INSERT INTO c VALUES(1, 99); -- violation + INSERT INTO p VALUES(99); -- fix by inserting parent + COMMIT; + SELECT id, pid FROM c ORDER BY id; +} {1|99} + +do_execsql_test_on_specific_db {:memory:} fk-deferred-insert-multi-children-one-parent-fix { + PRAGMA foreign_keys=ON; + CREATE TABLE p(id INTEGER PRIMARY KEY); + CREATE TABLE c(id INTEGER PRIMARY KEY, pid INT REFERENCES p(id) DEFERRABLE INITIALLY DEFERRED); + BEGIN; + INSERT INTO c VALUES(1, 50); + INSERT INTO c VALUES(2, 50); -- two violations pointing to same parent + INSERT INTO p VALUES(50); -- one parent fixes both + COMMIT; + SELECT id, pid FROM c ORDER BY id; +} {1|50 2|50} + +do_execsql_test_on_specific_db {:memory:} fk-deferred-insert-then-delete-child-fix { + PRAGMA foreign_keys=ON; + CREATE TABLE p(id INTEGER PRIMARY KEY); + CREATE TABLE c(id INTEGER PRIMARY KEY, pid INT REFERENCES p(id) DEFERRABLE INITIALLY DEFERRED); + BEGIN; + INSERT INTO c VALUES(1, 77); -- violation + DELETE FROM c WHERE id=1; -- resolve by removing the child + COMMIT; + SELECT count(*) FROM c; +} {0} + +do_execsql_test_on_specific_db {:memory:} fk-deferred-insert-self-ref-succeeds { + PRAGMA foreign_keys=ON; + CREATE TABLE t( + id INTEGER PRIMARY KEY, + pid INT REFERENCES t(id) DEFERRABLE INITIALLY DEFERRED + ); + BEGIN; + INSERT INTO t VALUES(1, 1); -- self-reference, legal at COMMIT + COMMIT; + SELECT id, pid FROM t; +} {1|1} + +do_execsql_test_in_memory_any_error fk-deferred-update-child-breaks-commit-fails { + PRAGMA foreign_keys=ON; + CREATE TABLE p(id INTEGER PRIMARY KEY); + CREATE TABLE c(id INTEGER PRIMARY KEY, pid INT REFERENCES p(id) DEFERRABLE INITIALLY DEFERRED); + INSERT INTO p VALUES(10); + INSERT INTO c VALUES(1, 10); -- valid + BEGIN; + UPDATE c SET pid=99 WHERE id=1; -- create violation + COMMIT; -- must fail +} + +do_execsql_test_on_specific_db {:memory:} fk-deferred-update-child-fix-before-commit { + PRAGMA foreign_keys=ON; + CREATE TABLE p(id INTEGER PRIMARY KEY); + CREATE TABLE c(id INTEGER PRIMARY KEY, pid INT REFERENCES p(id) DEFERRABLE INITIALLY DEFERRED); + INSERT INTO p VALUES(10); + INSERT INTO c VALUES(1, 10); + BEGIN; + UPDATE c SET pid=99 WHERE id=1; -- violation + UPDATE c SET pid=10 WHERE id=1; -- fix child back + COMMIT; + SELECT id, pid FROM c; +} {1|10} + +do_execsql_test_on_specific_db {:memory:} fk-deferred-update-child-fix-by-inserting-parent { + PRAGMA foreign_keys=ON; + CREATE TABLE p(id INTEGER PRIMARY KEY); + CREATE TABLE c(id INTEGER PRIMARY KEY, pid INT REFERENCES p(id) DEFERRABLE INITIALLY DEFERRED); + INSERT INTO p VALUES(10); + INSERT INTO c VALUES(1, 10); + BEGIN; + UPDATE c SET pid=50 WHERE id=1; -- violation + INSERT INTO p VALUES(50); -- fix by adding parent + COMMIT; + SELECT id, pid FROM c; +} {1|50} + +do_execsql_test_in_memory_any_error fk-deferred-update-parent-breaks-commit-fails { + PRAGMA foreign_keys=ON; + CREATE TABLE p(id INTEGER PRIMARY KEY); + CREATE TABLE c(id INTEGER PRIMARY KEY, pid INT REFERENCES p(id) DEFERRABLE INITIALLY DEFERRED); + INSERT INTO p VALUES(32); + INSERT INTO c VALUES(1, 32); -- valid + BEGIN; + UPDATE p SET id=50 WHERE id=32; -- break child reference + COMMIT; -- must fail (no fix) +} + +do_execsql_test_on_specific_db {:memory:} fk-deferred-update-parent-fix-by-updating-child { + PRAGMA foreign_keys=ON; + CREATE TABLE p(id INTEGER PRIMARY KEY); + CREATE TABLE c(id INTEGER PRIMARY KEY, pid INT REFERENCES p(id) DEFERRABLE INITIALLY DEFERRED); + INSERT INTO p VALUES(32); + INSERT INTO c VALUES(1, 32); + BEGIN; + UPDATE p SET id=50 WHERE id=32; -- break + UPDATE c SET pid=50 WHERE id=1; -- fix child to new parent key + COMMIT; + SELECT id, pid FROM c; +} {1|50} + +do_execsql_test_on_specific_db {:memory:} fk-deferred-update-parent-fix-by-reverting-parent { + PRAGMA foreign_keys=ON; + CREATE TABLE p(id INTEGER PRIMARY KEY); + CREATE TABLE c(id INTEGER PRIMARY KEY, pid INT REFERENCES p(id) DEFERRABLE INITIALLY DEFERRED); + INSERT INTO p VALUES(32); + INSERT INTO c VALUES(1, 32); + BEGIN; + UPDATE p SET id=50 WHERE id=32; -- break + UPDATE p SET id=32 WHERE id=50; -- revert (fix) + COMMIT; + SELECT id, pid FROM c; +} {1|32} + +do_execsql_test_on_specific_db {:memory:} fk-deferred-update-self-ref-id-change-and-fix { + PRAGMA foreign_keys=ON; + CREATE TABLE t( + id INTEGER PRIMARY KEY, + pid INT REFERENCES t(id) DEFERRABLE INITIALLY DEFERRED + ); + INSERT INTO t VALUES(1,1); + BEGIN; + UPDATE t SET id=2 WHERE id=1; -- break self-ref + UPDATE t SET pid=2 WHERE id=2; -- fix to new self + COMMIT; + SELECT id, pid FROM t; +} {2|2} + +do_execsql_test_in_memory_any_error fk-deferred-delete-parent-commit-fails { + PRAGMA foreign_keys=ON; + CREATE TABLE p(id INTEGER PRIMARY KEY); + CREATE TABLE c(id INTEGER PRIMARY KEY, pid INT REFERENCES p(id) DEFERRABLE INITIALLY DEFERRED); + INSERT INTO p VALUES(10); + INSERT INTO c VALUES(1, 10); -- valid + BEGIN; + DELETE FROM p WHERE id=10; -- break reference + COMMIT; -- must fail +} + +do_execsql_test_on_specific_db {:memory:} fk-deferred-delete-parent-then-delete-child-fix { + PRAGMA foreign_keys=ON; + CREATE TABLE p(id INTEGER PRIMARY KEY); + CREATE TABLE c(id INTEGER PRIMARY KEY, pid INT REFERENCES p(id) DEFERRABLE INITIALLY DEFERRED); + INSERT INTO p VALUES(10); + INSERT INTO c VALUES(1, 10); + BEGIN; + DELETE FROM p WHERE id=10; -- break + DELETE FROM c WHERE id=1; -- fix by removing child + COMMIT; + SELECT count(*) FROM p, c; -- both empty +} {0} + +do_execsql_test_on_specific_db {:memory:} fk-deferred-delete-parent-then-reinsert-parent-fix { + PRAGMA foreign_keys=ON; + CREATE TABLE p(id INTEGER PRIMARY KEY); + CREATE TABLE c(id INTEGER PRIMARY KEY, pid INT REFERENCES p(id) DEFERRABLE INITIALLY DEFERRED); + INSERT INTO p VALUES(10); + INSERT INTO c VALUES(1, 10); + BEGIN; + DELETE FROM p WHERE id=10; -- break + INSERT INTO p VALUES(10); -- fix by re-creating parent + COMMIT; + SELECT id, pid FROM c; +} {1|10} + +do_execsql_test_on_specific_db {:memory:} fk-deferred-delete-self-ref-row-ok { + PRAGMA foreign_keys=ON; + CREATE TABLE t( + id INTEGER PRIMARY KEY, + pid INT REFERENCES t(id) DEFERRABLE INITIALLY DEFERRED + ); + INSERT INTO t VALUES(1,1); -- valid + BEGIN; + DELETE FROM t WHERE id=1; -- removes both child+parent (same row) + COMMIT; -- should succeed + SELECT count(*) FROM t; +} {0} + +do_execsql_test_on_specific_db {:memory:} fk-deferred-delete-parent-then-update-child-to-null-fix { + PRAGMA foreign_keys=ON; + CREATE TABLE p(id INTEGER PRIMARY KEY); + CREATE TABLE c( + id INTEGER PRIMARY KEY, + pid INT REFERENCES p(id) DEFERRABLE INITIALLY DEFERRED + ); + INSERT INTO p VALUES(5); + INSERT INTO c VALUES(1,5); + BEGIN; + DELETE FROM p WHERE id=5; -- break + UPDATE c SET pid=NULL WHERE id=1; -- fix (NULL never violates) + COMMIT; + SELECT id, pid FROM c; +} {1|} + +# AUTOCOMMIT: deferred FK still fails at end-of-statement +do_execsql_test_in_memory_any_error fk-deferred-autocommit-insert-missing-parent { + PRAGMA foreign_keys=ON; + CREATE TABLE parent(id INTEGER PRIMARY KEY); + CREATE TABLE child(id INTEGER PRIMARY KEY, pid INT REFERENCES parent(id) DEFERRABLE INITIALLY DEFERRED); + INSERT INTO child VALUES(1, 3); -- no BEGIN; should fail at statement end +} + +# AUTOCOMMIT: self-referential insert is OK (parent is same row) +do_execsql_test_on_specific_db {:memory:} fk-deferred-autocommit-selfref-ok { + PRAGMA foreign_keys=ON; + CREATE TABLE t(id INTEGER PRIMARY KEY, pid INT REFERENCES t(id) DEFERRABLE INITIALLY DEFERRED); + INSERT INTO t VALUES(1,1); + SELECT * FROM t; +} {1|1} + +# AUTOCOMMIT: deleting a parent that has a child → fails at statement end +do_execsql_test_in_memory_any_error fk-deferred-autocommit-delete-parent-fails { + PRAGMA foreign_keys=ON; + CREATE TABLE p(id INTEGER PRIMARY KEY); + CREATE TABLE c(id INTEGER PRIMARY KEY, pid INT REFERENCES p(id) DEFERRABLE INITIALLY DEFERRED); + INSERT INTO p VALUES(1); + INSERT INTO c VALUES(10,1); + DELETE FROM p WHERE id=1; -- no BEGIN; should fail at statement end +} + +# TX: delete a referenced parent then reinsert before COMMIT -> OK +do_execsql_test_on_specific_db {:memory:} fk-deferred-tx-delete-parent-then-reinsert-ok { + PRAGMA foreign_keys=ON; + CREATE TABLE p(id INTEGER PRIMARY KEY); + CREATE TABLE c(id INTEGER PRIMARY KEY, pid INT REFERENCES p(id) DEFERRABLE INITIALLY DEFERRED); + INSERT INTO p VALUES(5); + INSERT INTO c VALUES(1,5); + BEGIN; + DELETE FROM p WHERE id=5; -- violation (deferred) + INSERT INTO p VALUES(5); -- fix in same tx + COMMIT; + SELECT count(*) FROM p WHERE id=5; +} {1} + +# TX: multiple violating children, later insert parent, COMMIT -> OK +do_execsql_test_on_specific_db {:memory:} fk-deferred-tx-multi-children-fixed-ok { + PRAGMA foreign_keys=ON; + CREATE TABLE p(id INTEGER PRIMARY KEY); + CREATE TABLE c(id INTEGER PRIMARY KEY, pid INT REFERENCES p(id) DEFERRABLE INITIALLY DEFERRED); + BEGIN; + INSERT INTO c VALUES(1,99); + INSERT INTO c VALUES(2,99); + INSERT INTO p VALUES(99); + COMMIT; + SELECT id,pid FROM c ORDER BY id; +} {1|99 2|99} + +# one of several children left unfixed -> COMMIT fails +do_execsql_test_in_memory_any_error fk-deferred-tx-multi-children-one-left-fails { + PRAGMA foreign_keys=ON; + CREATE TABLE p(id INTEGER PRIMARY KEY); + CREATE TABLE c(id INTEGER PRIMARY KEY, pid INT REFERENCES p(id) DEFERRABLE INITIALLY DEFERRED); + BEGIN; + INSERT INTO c VALUES(1,42); + INSERT INTO c VALUES(2,42); + INSERT INTO p VALUES(42); + UPDATE c SET pid=777 WHERE id=2; -- reintroduce a bad reference + COMMIT; -- should fail +} + +# composite PK parent, fix via parent UPDATE before COMMIT -> OK +do_execsql_test_on_specific_db {:memory:} fk-deferred-composite-parent-update-fix { + PRAGMA foreign_keys=ON; + CREATE TABLE parent(a INT NOT NULL, b INT NOT NULL, PRIMARY KEY(a,b)); + CREATE TABLE child(id INT PRIMARY KEY, ca INT, cb INT, + FOREIGN KEY(ca,cb) REFERENCES parent(a,b) DEFERRABLE INITIALLY DEFERRED); + INSERT INTO parent VALUES(1,1); + BEGIN; + INSERT INTO child VALUES(10, 7, 7); -- violation + UPDATE parent SET a=7, b=7 WHERE a=1 AND b=1; -- fix composite PK + COMMIT; + SELECT id, ca, cb FROM child; +} {10|7|7} + +# TX: NULL in child FK -> never a violation +do_execsql_test_on_specific_db {:memory:} fk-deferred-null-fk-never-violates { + PRAGMA foreign_keys=ON; + CREATE TABLE p(id INTEGER PRIMARY KEY); + CREATE TABLE c(id INTEGER PRIMARY KEY, pid INT REFERENCES p(id) DEFERRABLE INITIALLY DEFERRED); + BEGIN; + INSERT INTO c VALUES(1, NULL); -- always OK + COMMIT; + SELECT id, pid FROM c; +} {1|} + +# TX: child UPDATE to NULL resolves before COMMIT +do_execsql_test_on_specific_db {:memory:} fk-deferred-update-child-null-resolves { + PRAGMA foreign_keys=ON; + CREATE TABLE p(id INTEGER PRIMARY KEY); + CREATE TABLE c(id INTEGER PRIMARY KEY, pid INT REFERENCES p(id) DEFERRABLE INITIALLY DEFERRED); + BEGIN; + INSERT INTO c VALUES(1, 500); -- violation + UPDATE c SET pid=NULL WHERE id=1; -- resolves + COMMIT; + SELECT * FROM c; +} {1|} + +# TX: delete violating child resolves before COMMIT +do_execsql_test_on_specific_db {:memory:} fk-deferred-delete-child-resolves { + PRAGMA foreign_keys=ON; + CREATE TABLE p(id INTEGER PRIMARY KEY); + CREATE TABLE c(id INTEGER PRIMARY KEY, pid INT REFERENCES p(id) DEFERRABLE INITIALLY DEFERRED); + BEGIN; + INSERT INTO c VALUES(1, 777); -- violation + DELETE FROM c WHERE id=1; -- resolves + COMMIT; + SELECT count(*) FROM c; +} {0} + +# TX: update parent PK to match child before COMMIT -> OK +do_execsql_test_on_specific_db {:memory:} fk-deferred-update-parent-pk-resolves { + PRAGMA foreign_keys=ON; + CREATE TABLE p(id INTEGER PRIMARY KEY); + CREATE TABLE c(id INTEGER PRIMARY KEY, pid INT REFERENCES p(id) DEFERRABLE INITIALLY DEFERRED); + INSERT INTO p VALUES(10); + BEGIN; + INSERT INTO c VALUES(1, 20); -- violation + UPDATE p SET id=20 WHERE id=10; -- resolve via parent + COMMIT; + SELECT * FROM c; +} {1|20} + +# Two-table cycle; both inserted before COMMIT -> OK +do_execsql_test_on_specific_db {:memory:} fk-deferred-cycle-two-tables-ok { + PRAGMA foreign_keys=ON; + CREATE TABLE a(id INT PRIMARY KEY, b_id INT, FOREIGN KEY(b_id) REFERENCES b(id) DEFERRABLE INITIALLY DEFERRED); + CREATE TABLE b(id INT PRIMARY KEY, a_id INT, FOREIGN KEY(a_id) REFERENCES a(id) DEFERRABLE INITIALLY DEFERRED); + BEGIN; + INSERT INTO a VALUES(1, 1); -- refers to b(1) (not yet present) + INSERT INTO b VALUES(1, 1); -- refers to a(1) + COMMIT; + SELECT count(b.id), count(a.id) FROM a, b; +} {1|1} + +# Delete a row that self-references (child==parent) within a tx -> OK +do_execsql_test_on_specific_db {:memory:} fk-deferred-selfref-delete-ok { + PRAGMA foreign_keys=ON; + CREATE TABLE t(id INTEGER PRIMARY KEY, pid INT REFERENCES t(id) DEFERRABLE INITIALLY DEFERRED); + INSERT INTO t VALUES(1,1); + BEGIN; + DELETE FROM t WHERE id=1; + COMMIT; + SELECT count(*) FROM t; +} {0} + + +do_execsql_test_on_specific_db {:memory:} fk-parentcomp-donothing-noconflict-ok { + PRAGMA foreign_keys=ON; + CREATE TABLE parent (id INTEGER PRIMARY KEY, a INT, b INT); + CREATE TABLE child_deferred ( + id INTEGER PRIMARY KEY, pid INT, x INT, + FOREIGN KEY(pid) REFERENCES parent(id) DEFERRABLE INITIALLY DEFERRED + ); + CREATE TABLE parent_comp (a INT NOT NULL, b INT NOT NULL, c INT, PRIMARY KEY(a,b)); + CREATE TABLE child_comp_deferred ( + id INTEGER PRIMARY KEY, ca INT, cb INT, z INT, + FOREIGN KEY (ca,cb) REFERENCES parent_comp(a,b) DEFERRABLE INITIALLY DEFERRED + ); + + -- No conflict on (a,b); should insert 1 row, no FK noise + INSERT INTO parent_comp VALUES (-1,-1,9) ON CONFLICT DO NOTHING; + SELECT a,b,c FROM parent_comp ORDER BY a,b; +} {-1|-1|9} + +do_execsql_test_on_specific_db {:memory:} fk-parentcomp-donothing-conflict-noop { + PRAGMA foreign_keys=ON; + CREATE TABLE parent_comp (a INT NOT NULL, b INT NOT NULL, c INT, PRIMARY KEY(a,b)); + CREATE TABLE child_comp_deferred ( + id INTEGER PRIMARY KEY, ca INT, cb INT, z INT, + FOREIGN KEY (ca,cb) REFERENCES parent_comp(a,b) DEFERRABLE INITIALLY DEFERRED + ); + + INSERT INTO parent_comp VALUES (10,20,1); + -- Conflicts with existing (10,20); must do nothing (no triggers, no FK scans that mutate counters) + INSERT INTO parent_comp VALUES (10,20,999) ON CONFLICT DO NOTHING; + SELECT a,b,c FROM parent_comp; +} {10|20|1} + +do_execsql_test_on_specific_db {:memory:} fk-parentcomp-donothing-unrelated-immediate-ok { + PRAGMA foreign_keys=ON; + CREATE TABLE parent (id INTEGER PRIMARY KEY); + CREATE TABLE child_immediate ( + id INTEGER PRIMARY KEY, pid INT, + FOREIGN KEY(pid) REFERENCES parent(id) -- IMMEDIATE + ); + CREATE TABLE parent_comp (a INT NOT NULL, b INT NOT NULL, c INT, PRIMARY KEY(a,b)); + CREATE TABLE child_comp_deferred ( + id INTEGER PRIMARY KEY, ca INT, cb INT, z INT, + FOREIGN KEY(ca,cb) REFERENCES parent_comp(a,b) DEFERRABLE INITIALLY DEFERRED + ); + + INSERT INTO parent_comp VALUES (-1,-1,9) ON CONFLICT DO NOTHING; + SELECT a,b,c FROM parent_comp; +} {-1|-1|9} + +do_execsql_test_on_specific_db {:memory:} fk-parentcomp-deferred-fix-inside-tx-ok { + PRAGMA foreign_keys=ON; + CREATE TABLE parent_comp (a INT NOT NULL, b INT NOT NULL, c INT, PRIMARY KEY(a,b)); + CREATE TABLE child_comp_deferred ( + id INTEGER PRIMARY KEY, ca INT, cb INT, + FOREIGN KEY(ca,cb) REFERENCES parent_comp(a,b) DEFERRABLE INITIALLY DEFERRED + ); + + BEGIN; + INSERT INTO child_comp_deferred VALUES (1, -5, -6); -- violation + INSERT INTO parent_comp VALUES (-5, -6, 9); -- fix via parent insert + COMMIT; + SELECT id,ca,cb FROM child_comp_deferred; +} {1|-5|-6} + +do_execsql_test_on_specific_db {:memory:} fk-parentcomp-autocommit-unrelated-children-ok { + PRAGMA foreign_keys=ON; + CREATE TABLE parent_comp (a INT NOT NULL, b INT NOT NULL, c INT, PRIMARY KEY(a,b)); + CREATE TABLE child_comp_deferred ( + id INTEGER PRIMARY KEY, ca INT, cb INT, + FOREIGN KEY(ca,cb) REFERENCES parent_comp(a,b) DEFERRABLE INITIALLY DEFERRED + ); + + INSERT INTO parent_comp VALUES (1,1,0); + INSERT INTO child_comp_deferred VALUES (10,1,1); -- valid + INSERT INTO parent_comp VALUES (2,2,0) ON CONFLICT DO NOTHING; -- unrelated insert; must not raise + SELECT a,b,c FROM parent_comp ORDER BY a,b; +} {1|1|0 +2|2|0} + +# ROLLBACK must clear any deferred state; next statement must not trip. +do_execsql_test_on_specific_db {:memory:} fk-rollback-clears-then-donothing-ok { + PRAGMA foreign_keys=ON; + CREATE TABLE p(id INTEGER PRIMARY KEY); + CREATE TABLE c(id INTEGER PRIMARY KEY, pid INT REFERENCES p(id) DEFERRABLE INITIALLY DEFERRED); + CREATE TABLE parent_comp(a INT NOT NULL, b INT NOT NULL, c INT, PRIMARY KEY(a,b)); + + BEGIN; + INSERT INTO c VALUES(1, 456); -- create deferred violation + ROLLBACK; -- must clear counters + + INSERT INTO parent_comp VALUES(-2,-2,0) ON CONFLICT DO NOTHING; + SELECT a,b,c FROM parent_comp; +} {-2|-2|0} + +# DO NOTHING conflict path must touch no FK maintenance at all. +do_execsql_test_on_specific_db {:memory:} fk-parentcomp-donothing-conflict-stays-quiet { + PRAGMA foreign_keys=ON; + CREATE TABLE parent_comp(a INT NOT NULL, b INT NOT NULL, c INT, PRIMARY KEY(a,b)); + CREATE TABLE child_comp_deferred( + id INTEGER PRIMARY KEY, ca INT, cb INT, + FOREIGN KEY(ca,cb) REFERENCES parent_comp(a,b) DEFERRABLE INITIALLY DEFERRED + ); + + INSERT INTO parent_comp VALUES(10,20,1); + -- This conflicts with (10,20) and must be a no-op; if counters move here, it’s a bug. + INSERT INTO parent_comp VALUES(10,20,999) ON CONFLICT DO NOTHING; + + -- Prove DB is sane afterwards (no stray FK error) + INSERT INTO parent_comp VALUES(11,22,3) ON CONFLICT DO NOTHING; + SELECT a,b FROM parent_comp ORDER BY a,b; +} {10|20 +11|22} + +# Two-statement fix inside an explicit transaction (separate statements). +#Insert child (violation), then insert parent in a new statement; commit must pass. +do_execsql_test_on_specific_db {:memory:} fk-deferred-two-stmt-fix-inside-tx-ok { + PRAGMA foreign_keys=ON; + CREATE TABLE p(id INTEGER PRIMARY KEY); + CREATE TABLE c(id INTEGER PRIMARY KEY, pid INT REFERENCES p(id) DEFERRABLE INITIALLY DEFERRED); + BEGIN; + INSERT INTO c VALUES(1, 777); -- violation recorded in tx + INSERT INTO p VALUES(777); -- next statement fixes it + COMMIT; + SELECT * FROM c; +} {1|777} + +do_execsql_test_on_specific_db {:memory:} fk-delete-composite-bounds { + PRAGMA foreign_keys=ON; + CREATE TABLE p(a INT NOT NULL, b INT NOT NULL, v INT, PRIMARY KEY(a,b)); + CREATE TABLE c(id INTEGER PRIMARY KEY, x INT, y INT, w INT, + FOREIGN KEY(x,y) REFERENCES p(a,b)); + + INSERT INTO p VALUES (5,1,0),(5,2,0),(5,4,0); + INSERT INTO c VALUES (1,5,4,0); -- child references (5,4) + + -- This should be a no-op (no row (5,3)), and MUST NOT error. + DELETE FROM p WHERE a=5 AND b=3; +} {} diff --git a/tests/integration/fuzz/mod.rs b/tests/integration/fuzz/mod.rs index a60d1fcc3..078df667b 100644 --- a/tests/integration/fuzz/mod.rs +++ b/tests/integration/fuzz/mod.rs @@ -2,7 +2,7 @@ pub mod grammar_generator; #[cfg(test)] mod tests { - use rand::seq::{IndexedRandom, SliceRandom}; + use rand::seq::{IndexedRandom, IteratorRandom, SliceRandom}; use rand::{Rng, SeedableRng}; use rand_chacha::ChaCha8Rng; use rusqlite::{params, types::Value}; @@ -645,19 +645,334 @@ mod tests { "Different results! limbo: {:?}, sqlite: {:?}, seed: {}, query: {}, table def: {}", limbo_rows, sqlite_rows, seed, query, table_defs[i] ); + } + } + } + #[test] + pub fn fk_deferred_constraints_fuzz() { + let _ = env_logger::try_init(); + let (mut rng, seed) = rng_from_time(); + println!("fk_deferred_constraints_fuzz seed: {seed}"); + + const OUTER_ITERS: usize = 10; + const INNER_ITERS: usize = 50; + + for outer in 0..OUTER_ITERS { + println!("fk_deferred_constraints_fuzz {}/{}", outer + 1, OUTER_ITERS); + + let limbo_db = TempDatabase::new_empty(true); + let sqlite_db = TempDatabase::new_empty(true); + let limbo = limbo_db.connect_limbo(); + let sqlite = rusqlite::Connection::open(sqlite_db.path.clone()).unwrap(); + + let mut stmts: Vec = Vec::new(); + let mut log_and_exec = |sql: &str| { + stmts.push(sql.to_string()); + sql.to_string() + }; + // Enable FKs + let s = log_and_exec("PRAGMA foreign_keys=ON"); + limbo_exec_rows(&limbo_db, &limbo, &s); + sqlite.execute(&s, params![]).unwrap(); + + // Mix of immediate and deferred FK constraints + let s = log_and_exec("CREATE TABLE parent(id INTEGER PRIMARY KEY, a INT, b INT)"); + limbo_exec_rows(&limbo_db, &limbo, &s); + sqlite.execute(&s, params![]).unwrap(); + + // Child with DEFERRABLE INITIALLY DEFERRED FK + let s = log_and_exec( + "CREATE TABLE child_deferred(id INTEGER PRIMARY KEY, pid INT, x INT, \ + FOREIGN KEY(pid) REFERENCES parent(id) DEFERRABLE INITIALLY DEFERRED)", + ); + limbo_exec_rows(&limbo_db, &limbo, &s); + sqlite.execute(&s, params![]).unwrap(); + + // Child with immediate FK (default) + let s = log_and_exec( + "CREATE TABLE child_immediate(id INTEGER PRIMARY KEY, pid INT, y INT, \ + FOREIGN KEY(pid) REFERENCES parent(id))", + ); + limbo_exec_rows(&limbo_db, &limbo, &s); + sqlite.execute(&s, params![]).unwrap(); + + // Composite key parent for deferred testing + let s = log_and_exec( + "CREATE TABLE parent_comp(a INT NOT NULL, b INT NOT NULL, c INT, PRIMARY KEY(a,b))", + ); + limbo_exec_rows(&limbo_db, &limbo, &s); + sqlite.execute(&s, params![]).unwrap(); + + // Child with composite deferred FK + let s = log_and_exec( + "CREATE TABLE child_comp_deferred(id INTEGER PRIMARY KEY, ca INT, cb INT, z INT, \ + FOREIGN KEY(ca,cb) REFERENCES parent_comp(a,b) DEFERRABLE INITIALLY DEFERRED)", + ); + limbo_exec_rows(&limbo_db, &limbo, &s); + sqlite.execute(&s, params![]).unwrap(); + + // Seed initial data + let mut parent_ids = std::collections::HashSet::new(); + for _ in 0..rng.random_range(10..=25) { + let id = rng.random_range(1..=50) as i64; + if parent_ids.insert(id) { + let a = rng.random_range(-5..=25); + let b = rng.random_range(-5..=25); + let stmt = log_and_exec(&format!("INSERT INTO parent VALUES ({id}, {a}, {b})")); + limbo_exec_rows(&limbo_db, &limbo, &stmt); + sqlite.execute(&stmt, params![]).unwrap(); + } + } + + // Seed composite parent + let mut comp_pairs = std::collections::HashSet::new(); + for _ in 0..rng.random_range(3..=10) { + let a = rng.random_range(-3..=6) as i64; + let b = rng.random_range(-3..=6) as i64; + if comp_pairs.insert((a, b)) { + let c = rng.random_range(0..=20); + let stmt = + log_and_exec(&format!("INSERT INTO parent_comp VALUES ({a}, {b}, {c})")); + limbo_exec_rows(&limbo_db, &limbo, &stmt); + sqlite.execute(&stmt, params![]).unwrap(); + } + } + + // Transaction-based mutations with mix of deferred and immediate operations + for tx_num in 0..INNER_ITERS { + // Decide if we're in a transaction + let mut in_tx = false; + let use_transaction = rng.random_bool(0.7); + + if use_transaction && !in_tx { + in_tx = true; + let s = log_and_exec("BEGIN"); + let sres = sqlite.execute(&s, params![]); + let lres = limbo_exec_rows_fallible(&limbo_db, &limbo, &s); + match (&sres, &lres) { + (Ok(_), Ok(_)) | (Err(_), Err(_)) => {} + _ => { + eprintln!("BEGIN mismatch"); + eprintln!("sqlite result: {sres:?}"); + eprintln!("limbo result: {lres:?}"); + let file = std::fs::File::create("fk_deferred.sql").unwrap(); + for stmt in stmts.iter() { + writeln!(&file, "{stmt};").unwrap(); + } + eprintln!("Wrote `tests/fk_deferred.sql` for debugging"); + eprintln!("turso path: {}", limbo_db.path.display()); + eprintln!("sqlite path: {}", sqlite_db.path.display()); + panic!("BEGIN mismatch"); + } + } + } + + let op = rng.random_range(0..12); + let stmt = match op { + // Insert into child_deferred (can violate temporarily in transaction) + 0 => { + let id = rng.random_range(1000..=2000); + let pid = if rng.random_bool(0.6) { + *parent_ids.iter().choose(&mut rng).unwrap_or(&1) + } else { + // Non-existent parent - OK if deferred and fixed before commit + rng.random_range(200..=300) as i64 + }; + let x = rng.random_range(-10..=10); + format!("INSERT INTO child_deferred VALUES ({id}, {pid}, {x})") + } + // Insert into child_immediate (must satisfy FK immediately) + 1 => { + let id = rng.random_range(3000..=4000); + let pid = if rng.random_bool(0.8) { + *parent_ids.iter().choose(&mut rng).unwrap_or(&1) + } else { + rng.random_range(200..=300) as i64 + }; + let y = rng.random_range(-10..=10); + format!("INSERT INTO child_immediate VALUES ({id}, {pid}, {y})") + } + // Insert parent (may fix deferred violations) + 2 => { + let id = rng.random_range(1..=300); + let a = rng.random_range(-5..=25); + let b = rng.random_range(-5..=25); + parent_ids.insert(id as i64); + format!("INSERT INTO parent VALUES ({id}, {a}, {b})") + } + // Delete parent (may cause violations) + 3 => { + let id = if rng.random_bool(0.5) { + *parent_ids.iter().choose(&mut rng).unwrap_or(&1) + } else { + rng.random_range(1..=300) as i64 + }; + format!("DELETE FROM parent WHERE id={id}") + } + // Update parent PK + 4 => { + let old = rng.random_range(1..=300); + let new = rng.random_range(1..=350); + format!("UPDATE parent SET id={new} WHERE id={old}") + } + // Update child_deferred FK + 5 => { + let id = rng.random_range(1000..=2000); + let pid = if rng.random_bool(0.5) { + *parent_ids.iter().choose(&mut rng).unwrap_or(&1) + } else { + rng.random_range(200..=400) as i64 + }; + format!("UPDATE child_deferred SET pid={pid} WHERE id={id}") + } + // Insert into composite deferred child + 6 => { + let id = rng.random_range(5000..=6000); + let (ca, cb) = if rng.random_bool(0.6) { + *comp_pairs.iter().choose(&mut rng).unwrap_or(&(1, 1)) + } else { + // Non-existent composite parent + ( + rng.random_range(-5..=8) as i64, + rng.random_range(-5..=8) as i64, + ) + }; + let z = rng.random_range(0..=10); + format!( + "INSERT INTO child_comp_deferred VALUES ({id}, {ca}, {cb}, {z}) ON CONFLICT DO NOTHING" + ) + } + // Insert composite parent + 7 => { + let a = rng.random_range(-5..=8) as i64; + let b = rng.random_range(-5..=8) as i64; + let c = rng.random_range(0..=20); + comp_pairs.insert((a, b)); + format!("INSERT INTO parent_comp VALUES ({a}, {b}, {c})") + } + // UPSERT with deferred child + 8 => { + let id = rng.random_range(1000..=2000); + let pid = if rng.random_bool(0.5) { + *parent_ids.iter().choose(&mut rng).unwrap_or(&1) + } else { + rng.random_range(200..=400) as i64 + }; + let x = rng.random_range(-10..=10); + format!( + "INSERT INTO child_deferred VALUES ({id}, {pid}, {x}) + ON CONFLICT(id) DO UPDATE SET pid=excluded.pid, x=excluded.x" + ) + } + // Delete from child_deferred + 9 => { + let id = rng.random_range(1000..=2000); + format!("DELETE FROM child_deferred WHERE id={id}") + } + // Self-referential deferred insert (create temp violation then fix) + 10 if use_transaction => { + let id = rng.random_range(400..=500); + let pid = id + 1; // References non-existent yet + format!("INSERT INTO child_deferred VALUES ({id}, {pid}, 0)") + } + _ => { + // Default: simple parent insert + let id = rng.random_range(1..=300); + format!("INSERT INTO parent VALUES ({id}, 0, 0)") + } + }; + + let stmt = log_and_exec(&stmt); + let sres = sqlite.execute(&stmt, params![]); + let lres = limbo_exec_rows_fallible(&limbo_db, &limbo, &stmt); + + if !use_transaction && !in_tx { + match (sres, lres) { + (Ok(_), Ok(_)) | (Err(_), Err(_)) => {} + (s, l) => { + eprintln!("Non-tx mismatch: sqlite={s:?}, limbo={l:?}"); + eprintln!("Statement: {stmt}"); + eprintln!("Seed: {seed}, outer: {outer}, tx: {tx_num}"); + let mut file = std::fs::File::create("fk_deferred.sql").unwrap(); + for stmt in stmts.iter() { + writeln!(file, "{stmt};").expect("write to file"); + } + eprintln!("turso path: {}", limbo_db.path.display()); + eprintln!("sqlite path: {}", sqlite_db.path.display()); + panic!("Non-transactional operation mismatch, file written to 'tests/fk_deferred.sql'"); + } + } + } + + if use_transaction && in_tx { + // Randomly COMMIT or ROLLBACK + let commit = rng.random_bool(0.7); + let s = log_and_exec("COMMIT"); + + let sres = sqlite.execute(&s, params![]); + let lres = limbo_exec_rows_fallible(&limbo_db, &limbo, &s); + + match (sres, lres) { + (Ok(_), Ok(_)) => {} + (Err(_), Err(_)) => { + // Both failed - OK, deferred constraint violation at commit + if commit && in_tx { + in_tx = false; + let s = if commit { + log_and_exec("ROLLBACK") + } else { + log_and_exec("SELECT 1") // noop if we already rolled back + }; + + let sres = sqlite.execute(&s, params![]); + let lres = limbo_exec_rows_fallible(&limbo_db, &limbo, &s); + match (sres, lres) { + (Ok(_), Ok(_)) => {} + (s, l) => { + eprintln!("Post-failed-commit cleanup mismatch: sqlite={s:?}, limbo={l:?}"); + let mut file = + std::fs::File::create("fk_deferred.sql").unwrap(); + for stmt in stmts.iter() { + writeln!(file, "{stmt};").expect("write to file"); + } + eprintln!("turso path: {}", limbo_db.path.display()); + eprintln!("sqlite path: {}", sqlite_db.path.display()); + panic!("Post-failed-commit cleanup mismatch, file written to 'tests/fk_deferred.sql'"); + } + } + } + } + (s, l) => { + eprintln!("\n=== COMMIT/ROLLBACK mismatch ==="); + eprintln!("Operation: {s:?}"); + eprintln!("sqlite={s:?}, limbo={l:?}"); + eprintln!("Seed: {seed}, outer: {outer}, tx: {tx_num}"); + eprintln!("--- Replay statements ({}) ---", stmts.len()); + let mut file = std::fs::File::create("fk_deferred.sql").unwrap(); + for stmt in stmts.iter() { + writeln!(file, "{stmt};").expect("write to file"); + } + eprintln!("Turso path: {}", limbo_db.path.display()); + eprintln!("Sqlite path: {}", sqlite_db.path.display()); + panic!( + "outcome mismatch, .sql file written to `tests/fk_deferred.sql`" + ); + } + } } } } } + #[test] pub fn fk_single_pk_mutation_fuzz() { let _ = env_logger::try_init(); let (mut rng, seed) = rng_from_time(); println!("fk_single_pk_mutation_fuzz seed: {seed}"); - const OUTER_ITERS: usize = 50; - const INNER_ITERS: usize = 200; + const OUTER_ITERS: usize = 20; + const INNER_ITERS: usize = 100; for outer in 0..OUTER_ITERS { println!("fk_single_pk_mutation_fuzz {}/{}", outer + 1, OUTER_ITERS); @@ -679,7 +994,6 @@ mod tests { limbo_exec_rows(&limbo_db, &limbo, &s); sqlite.execute(&s, params![]).unwrap(); - // DDL let s = log_and_exec("CREATE TABLE p(id INTEGER PRIMARY KEY, a INT, b INT)"); limbo_exec_rows(&limbo_db, &limbo, &s); sqlite.execute(&s, params![]).unwrap(); @@ -704,8 +1018,14 @@ mod tests { let a = rng.random_range(-5..=25); let b = rng.random_range(-5..=25); let stmt = log_and_exec(&format!("INSERT INTO p VALUES ({id}, {a}, {b})")); - limbo_exec_rows(&limbo_db, &limbo, &stmt); - sqlite.execute(&stmt, params![]).unwrap(); + let l_res = limbo_exec_rows_fallible(&limbo_db, &limbo, &stmt); + let s_res = sqlite.execute(&stmt, params![]); + match (l_res, s_res) { + (Ok(_), Ok(_)) | (Err(_), Err(_)) => {} + _ => { + panic!("Seeding parent insert mismatch"); + } + } } // Seed child @@ -817,8 +1137,7 @@ mod tests { let a = rng.random_range(-5..=25); let b = rng.random_range(-5..=25); format!( - "INSERT INTO p VALUES({pick}, {a}, {b}) \ - ON CONFLICT(id) DO UPDATE SET a=excluded.a, b=excluded.b" + "INSERT INTO p VALUES({pick}, {a}, {b}) ON CONFLICT(id) DO UPDATE SET a=excluded.a, b=excluded.b" ) } else { let a = rng.random_range(-5..=25); @@ -935,14 +1254,368 @@ mod tests { } #[test] - #[ignore] // TODO: un-ignore when UNIQUE constraints are fixed + pub fn fk_edgecases_fuzzing() { + let _ = env_logger::try_init(); + let (mut rng, seed) = rng_from_time(); + println!("fk_edgecases_minifuzz seed: {seed}"); + + const OUTER_ITERS: usize = 20; + const INNER_ITERS: usize = 100; + + fn assert_parity( + seed: u64, + stmts: &[String], + sqlite_res: rusqlite::Result, + limbo_res: Result>, turso_core::LimboError>, + last_stmt: &str, + tag: &str, + ) { + match (sqlite_res.is_ok(), limbo_res.is_ok()) { + (true, true) | (false, false) => (), + _ => { + eprintln!("\n=== {tag} mismatch ==="); + eprintln!("seed: {seed}"); + eprintln!("sqlite: {sqlite_res:?}, limbo: {limbo_res:?}"); + eprintln!("stmt: {last_stmt}"); + eprintln!("--- replay statements ({}) ---", stmts.len()); + for (i, s) in stmts.iter().enumerate() { + eprintln!("{:04}: {};", i + 1, s); + } + panic!("{tag}: engines disagree"); + } + } + } + + // parent rowid, child textified integers -> MustBeInt coercion path + for outer in 0..OUTER_ITERS { + let limbo_db = TempDatabase::new_empty(true); + let sqlite_db = TempDatabase::new_empty(true); + let limbo = limbo_db.connect_limbo(); + let sqlite = rusqlite::Connection::open(sqlite_db.path.clone()).unwrap(); + + let mut stmts: Vec = Vec::new(); + let log = |s: &str, stmts: &mut Vec| { + stmts.push(s.to_string()); + s.to_string() + }; + + for s in [ + "PRAGMA foreign_keys=ON", + "CREATE TABLE p(id INTEGER PRIMARY KEY, a INT)", + "CREATE TABLE c(id INTEGER PRIMARY KEY, x INT, FOREIGN KEY(x) REFERENCES p(id))", + ] { + let s = log(s, &mut stmts); + let _ = limbo_exec_rows_fallible(&limbo_db, &limbo, &s); + let _ = sqlite.execute(&s, params![]); + } + + // Seed a few parents + for _ in 0..rng.random_range(2..=5) { + let id = rng.random_range(1..=15); + let a = rng.random_range(-5..=5); + let s = log(&format!("INSERT INTO p VALUES({id},{a})"), &mut stmts); + let _ = limbo_exec_rows_fallible(&limbo_db, &limbo, &s); + let _ = sqlite.execute(&s, params![]); + } + + // try random child inserts with weird text-ints + for i in 0..INNER_ITERS { + let id = 1000 + i as i64; + let raw = if rng.random_bool(0.7) { + 1 + rng.random_range(0..=15) + } else { + rng.random_range(100..=200) as i64 + }; + + // Randomly decorate the integer as text with spacing/zeros/plus + let pad_left_zeros = rng.random_range(0..=2); + let spaces_left = rng.random_range(0..=2); + let spaces_right = rng.random_range(0..=2); + let plus = if rng.random_bool(0.3) { "+" } else { "" }; + let txt_num = format!( + "{plus}{:0width$}", + raw, + width = (1 + pad_left_zeros) as usize + ); + let txt = format!( + "'{}{}{}'", + " ".repeat(spaces_left), + txt_num, + " ".repeat(spaces_right) + ); + + let stmt = log(&format!("INSERT INTO c VALUES({id}, {txt})"), &mut stmts); + let sres = sqlite.execute(&stmt, params![]); + let lres = limbo_exec_rows_fallible(&limbo_db, &limbo, &stmt); + assert_parity(seed, &stmts, sres, lres, &stmt, "A: rowid-coercion"); + } + println!("A {}/{} ok", outer + 1, OUTER_ITERS); + } + + // slf-referential rowid FK + for outer in 0..OUTER_ITERS { + let limbo_db = TempDatabase::new_empty(true); + let sqlite_db = TempDatabase::new_empty(true); + let limbo = limbo_db.connect_limbo(); + let sqlite = rusqlite::Connection::open(sqlite_db.path.clone()).unwrap(); + + let mut stmts: Vec = Vec::new(); + let log = |s: &str, stmts: &mut Vec| { + stmts.push(s.to_string()); + s.to_string() + }; + + for s in [ + "PRAGMA foreign_keys=ON", + "CREATE TABLE t(id INTEGER PRIMARY KEY, rid REFERENCES t(id))", + ] { + let s = log(s, &mut stmts); + limbo_exec_rows(&limbo_db, &limbo, &s); + sqlite.execute(&s, params![]).unwrap(); + } + + // Self-match should succeed for many ids + for _ in 0..INNER_ITERS { + let id = rng.random_range(1..=500); + let stmt = log( + &format!("INSERT INTO t(id,rid) VALUES({id},{id})"), + &mut stmts, + ); + let sres = sqlite.execute(&stmt, params![]); + let lres = limbo_exec_rows_fallible(&limbo_db, &limbo, &stmt); + assert_parity(seed, &stmts, sres, lres, &stmt, "B1: self-row ok"); + } + + // Mismatch (rid != id) should fail (unless the referenced id already exists). + for _ in 0..rng.random_range(1..=10) { + let id = rng.random_range(1..=20); + let s = log( + &format!("INSERT INTO t(id,rid) VALUES({id},{id})"), + &mut stmts, + ); + let s_res = sqlite.execute(&s, params![]); + let turso_rs = limbo_exec_rows_fallible(&limbo_db, &limbo, &s); + match (s_res.is_ok(), turso_rs.is_ok()) { + (true, true) | (false, false) => {} + _ => panic!("Seeding self-ref failed differently"), + } + } + + for _ in 0..INNER_ITERS { + let id = rng.random_range(600..=900); + let ref_ = rng.random_range(1..=25); + let stmt = log( + &format!("INSERT INTO t(id,rid) VALUES({id},{ref_})"), + &mut stmts, + ); + let sres = sqlite.execute(&stmt, params![]); + let lres = limbo_exec_rows_fallible(&limbo_db, &limbo, &stmt); + assert_parity(seed, &stmts, sres, lres, &stmt, "B2: self-row mismatch"); + } + println!("B {}/{} ok", outer + 1, OUTER_ITERS); + } + + // self-referential UNIQUE(u,v) parent (fast-path for composite) + for outer in 0..OUTER_ITERS { + let limbo_db = TempDatabase::new_empty(true); + let sqlite_db = TempDatabase::new_empty(true); + let limbo = limbo_db.connect_limbo(); + let sqlite = rusqlite::Connection::open(sqlite_db.path.clone()).unwrap(); + + let mut stmts: Vec = Vec::new(); + let log = |s: &str, stmts: &mut Vec| { + stmts.push(s.to_string()); + s.to_string() + }; + + let s = log("PRAGMA foreign_keys=ON", &mut stmts); + limbo_exec_rows(&limbo_db, &limbo, &s); + sqlite.execute(&s, params![]).unwrap(); + + // Variant the schema a bit: TEXT/TEXT, NUMERIC/TEXT, etc. + let decls = [ + ("TEXT", "TEXT"), + ("TEXT", "NUMERIC"), + ("NUMERIC", "TEXT"), + ("TEXT", "BLOB"), + ]; + let (tu, tv) = decls[rng.random_range(0..decls.len())]; + + let s = log( + &format!( + "CREATE TABLE sr(u {tu}, v {tv}, cu {tu}, cv {tv}, UNIQUE(u,v), \ + FOREIGN KEY(cu,cv) REFERENCES sr(u,v))" + ), + &mut stmts, + ); + limbo_exec_rows(&limbo_db, &limbo, &s); + sqlite.execute(&s, params![]).unwrap(); + + // Self-matching composite rows should succeed + for _ in 0..INNER_ITERS { + // Random small tokens, possibly padded + let u = format!("U{}", rng.random_range(0..50)); + let v = format!("V{}", rng.random_range(0..50)); + let mut cu = u.clone(); + let mut cv = v.clone(); + + // occasionally wrap child refs as blobs/text to stress coercion on parent index + if rng.random_bool(0.2) { + // child cv as hex blob of ascii v + let hex: String = v.bytes().map(|b| format!("{b:02X}")).collect(); + cv = format!("x'{hex}'"); + } else { + cu = format!("'{cu}'"); + cv = format!("'{cv}'"); + } + + let stmt = log( + &format!("INSERT INTO sr(u,v,cu,cv) VALUES('{u}','{v}',{cu},{cv})"), + &mut stmts, + ); + let sres = sqlite.execute(&stmt, params![]); + let lres = limbo_exec_rows_fallible(&limbo_db, &limbo, &stmt); + assert_parity(seed, &stmts, sres, lres, &stmt, "C1: self-UNIQUE ok"); + } + + // Non-self-match likely fails unless earlier rows happen to satisfy (u,v) + for _ in 0..INNER_ITERS { + let u = format!("U{}", rng.random_range(60..100)); + let v = format!("V{}", rng.random_range(60..100)); + let cu = format!("'U{}'", rng.random_range(0..40)); + let cv = format!("'{}{}'", "V", rng.random_range(0..40)); + let stmt = log( + &format!("INSERT INTO sr(u,v,cu,cv) VALUES('{u}','{v}',{cu},{cv})"), + &mut stmts, + ); + let sres = sqlite.execute(&stmt, params![]); + let lres = limbo_exec_rows_fallible(&limbo_db, &limbo, &stmt); + assert_parity(seed, &stmts, sres, lres, &stmt, "C2: self-UNIQUE mismatch"); + } + println!("C {}/{} ok", outer + 1, OUTER_ITERS); + } + + // parent TEXT UNIQUE(u,v), child types differ; rely on parent-index affinities + for outer in 0..OUTER_ITERS { + let limbo_db = TempDatabase::new_empty(true); + let sqlite_db = TempDatabase::new_empty(true); + let limbo = limbo_db.connect_limbo(); + let sqlite = rusqlite::Connection::open(sqlite_db.path.clone()).unwrap(); + + let mut stmts: Vec = Vec::new(); + let log = |s: &str, stmts: &mut Vec| { + stmts.push(s.to_string()); + s.to_string() + }; + + for s in [ + "PRAGMA foreign_keys=ON", + "CREATE TABLE parent(u TEXT, v TEXT, UNIQUE(u,v))", + "CREATE TABLE child(id INTEGER PRIMARY KEY, cu INT, cv BLOB, \ + FOREIGN KEY(cu,cv) REFERENCES parent(u,v))", + ] { + let s = log(s, &mut stmts); + limbo_exec_rows(&limbo_db, &limbo, &s); + sqlite.execute(&s, params![]).unwrap(); + } + + for _ in 0..rng.random_range(3..=8) { + let u_raw = rng.random_range(0..=9); + let v_raw = rng.random_range(0..=9); + let u = if rng.random_bool(0.4) { + format!("+{u_raw}") + } else { + format!("{u_raw}") + }; + let v = if rng.random_bool(0.5) { + format!("{v_raw:02}",) + } else { + format!("{v_raw}") + }; + let s = log( + &format!("INSERT INTO parent VALUES('{u}','{v}')"), + &mut stmts, + ); + let l_res = limbo_exec_rows_fallible(&limbo_db, &limbo, &s); + let s_res = sqlite.execute(&s, params![]); + match (s_res, l_res) { + (Ok(_), Ok(_)) | (Err(_), Err(_)) => {} + (x, y) => { + panic!("Parent seeding mismatch: sqlite {x:?}, limbo {y:?}"); + } + } + } + + for i in 0..INNER_ITERS { + let id = i as i64 + 1; + let u_txt = if rng.random_bool(0.7) { + format!("+{}", rng.random_range(0..=9)) + } else { + format!("{}", rng.random_range(0..=9)) + }; + let v_txt = if rng.random_bool(0.5) { + format!("{:02}", rng.random_range(0..=9)) + } else { + format!("{}", rng.random_range(0..=9)) + }; + + // produce child literals that *look different* but should match under TEXT affinity + // cu uses integer-ish form of u; cv uses blob of ASCII v or quoted v randomly. + let cu = if let Ok(u_int) = u_txt.trim().trim_start_matches('+').parse::() { + if rng.random_bool(0.5) { + format!("{u_int}",) + } else { + format!("'{u_txt}'") + } + } else { + format!("'{u_txt}'") + }; + let cv = if rng.random_bool(0.6) { + let hex: String = v_txt + .as_bytes() + .iter() + .map(|b| format!("{b:02X}")) + .collect(); + format!("x'{hex}'") + } else { + format!("'{v_txt}'") + }; + + let stmt = log( + &format!("INSERT INTO child VALUES({id}, {cu}, {cv})"), + &mut stmts, + ); + let sres = sqlite.execute(&stmt, params![]); + let lres = limbo_exec_rows_fallible(&limbo_db, &limbo, &stmt); + assert_parity(seed, &stmts, sres, lres, &stmt, "D1: parent-index affinity"); + } + + for i in 0..(INNER_ITERS / 3) { + let id = 10_000 + i as i64; + let cu = rng.random_range(0..=9); + let miss = rng.random_range(10..=19); + let stmt = log( + &format!("INSERT INTO child VALUES({id}, {cu}, x'{miss:02X}')"), + &mut stmts, + ); + let sres = sqlite.execute(&stmt, params![]); + let lres = limbo_exec_rows_fallible(&limbo_db, &limbo, &stmt); + assert_parity(seed, &stmts, sres, lres, &stmt, "D2: parent-index negative"); + } + println!("D {}/{} ok", outer + 1, OUTER_ITERS); + } + + println!("fk_edgecases_minifuzz complete (seed {seed})"); + } + + #[test] pub fn fk_composite_pk_mutation_fuzz() { let _ = env_logger::try_init(); let (mut rng, seed) = rng_from_time(); println!("fk_composite_pk_mutation_fuzz seed: {seed}"); - const OUTER_ITERS: usize = 30; - const INNER_ITERS: usize = 200; + const OUTER_ITERS: usize = 10; + const INNER_ITERS: usize = 100; for outer in 0..OUTER_ITERS { println!(