Implement proper handling of deferred foreign keys

This commit is contained in:
PThorpe92
2025-10-01 13:54:13 -04:00
parent f56f37fae5
commit a232e3cc7a
15 changed files with 3262 additions and 1418 deletions

View File

@@ -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)
}

View File

@@ -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<ResolvedFkRef> {
pub fn resolved_fks_referencing(&self, table_name: &str) -> Result<Vec<ResolvedFkRef>> {
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("<unknown>", &target))?;
// Precompute helper to find parent unique index, if it's not the rowid
let find_parent_unique = |cols: &Vec<String>| -> Option<Arc<Index>> {
@@ -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<String> = 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<String> = 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<usize> = 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<usize> = 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<ResolvedFkRef> {
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<Vec<ResolvedFkRef>> {
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<String>| -> Option<Arc<Index>> {
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, "<unknown>"))?;
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<String> = 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<String> = 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<String> = 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<usize> = child_cols
.iter()
.map(|c| child.get_column(c).expect("child col missing").0)
.collect();
let parent_pos: Vec<usize> = 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)]

View File

@@ -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

File diff suppressed because it is too large Load Diff

1025
core/translate/fkeys.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -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 (dont 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<String> = 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::<Result<String, _>>()?
};
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(())
}

View File

@@ -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;

View File

@@ -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<usize> =
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,
)?;
}
}

View File

@@ -792,6 +792,9 @@ impl ProgramBuilder {
Insn::NotFound { target_pc, .. } => {
resolve(target_pc, "NotFound");
}
Insn::FkIfZero { target_pc, .. } => {
resolve(target_pc, "FkIfZero");
}
_ => {}
}
}

View File

@@ -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<Pager>,
_mv_store: Option<&Arc<MvStore>>,
) -> Result<InsnFunctionStepResult> {
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)
}

View File

@@ -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,

View File

@@ -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,
},
}

View File

@@ -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(()))
}
}

View File

@@ -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, its 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;
} {}

View File

@@ -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<String> = Vec::new();
let mut log_and_exec = |sql: &str| {
stmts.push(sql.to_string());
sql.to_string()
};
// Enable FKs
let s = log_and_exec("PRAGMA foreign_keys=ON");
limbo_exec_rows(&limbo_db, &limbo, &s);
sqlite.execute(&s, params![]).unwrap();
// Mix of immediate and deferred FK constraints
let s = log_and_exec("CREATE TABLE parent(id INTEGER PRIMARY KEY, a INT, b INT)");
limbo_exec_rows(&limbo_db, &limbo, &s);
sqlite.execute(&s, params![]).unwrap();
// Child with DEFERRABLE INITIALLY DEFERRED FK
let s = log_and_exec(
"CREATE TABLE child_deferred(id INTEGER PRIMARY KEY, pid INT, x INT, \
FOREIGN KEY(pid) REFERENCES parent(id) DEFERRABLE INITIALLY DEFERRED)",
);
limbo_exec_rows(&limbo_db, &limbo, &s);
sqlite.execute(&s, params![]).unwrap();
// Child with immediate FK (default)
let s = log_and_exec(
"CREATE TABLE child_immediate(id INTEGER PRIMARY KEY, pid INT, y INT, \
FOREIGN KEY(pid) REFERENCES parent(id))",
);
limbo_exec_rows(&limbo_db, &limbo, &s);
sqlite.execute(&s, params![]).unwrap();
// Composite key parent for deferred testing
let s = log_and_exec(
"CREATE TABLE parent_comp(a INT NOT NULL, b INT NOT NULL, c INT, PRIMARY KEY(a,b))",
);
limbo_exec_rows(&limbo_db, &limbo, &s);
sqlite.execute(&s, params![]).unwrap();
// Child with composite deferred FK
let s = log_and_exec(
"CREATE TABLE child_comp_deferred(id INTEGER PRIMARY KEY, ca INT, cb INT, z INT, \
FOREIGN KEY(ca,cb) REFERENCES parent_comp(a,b) DEFERRABLE INITIALLY DEFERRED)",
);
limbo_exec_rows(&limbo_db, &limbo, &s);
sqlite.execute(&s, params![]).unwrap();
// Seed initial data
let mut parent_ids = std::collections::HashSet::new();
for _ in 0..rng.random_range(10..=25) {
let id = rng.random_range(1..=50) as i64;
if parent_ids.insert(id) {
let a = rng.random_range(-5..=25);
let b = rng.random_range(-5..=25);
let stmt = log_and_exec(&format!("INSERT INTO parent VALUES ({id}, {a}, {b})"));
limbo_exec_rows(&limbo_db, &limbo, &stmt);
sqlite.execute(&stmt, params![]).unwrap();
}
}
// Seed composite parent
let mut comp_pairs = std::collections::HashSet::new();
for _ in 0..rng.random_range(3..=10) {
let a = rng.random_range(-3..=6) as i64;
let b = rng.random_range(-3..=6) as i64;
if comp_pairs.insert((a, b)) {
let c = rng.random_range(0..=20);
let stmt =
log_and_exec(&format!("INSERT INTO parent_comp VALUES ({a}, {b}, {c})"));
limbo_exec_rows(&limbo_db, &limbo, &stmt);
sqlite.execute(&stmt, params![]).unwrap();
}
}
// Transaction-based mutations with mix of deferred and immediate operations
for tx_num in 0..INNER_ITERS {
// Decide if we're in a transaction
let mut in_tx = false;
let use_transaction = rng.random_bool(0.7);
if use_transaction && !in_tx {
in_tx = true;
let s = log_and_exec("BEGIN");
let sres = sqlite.execute(&s, params![]);
let lres = limbo_exec_rows_fallible(&limbo_db, &limbo, &s);
match (&sres, &lres) {
(Ok(_), Ok(_)) | (Err(_), Err(_)) => {}
_ => {
eprintln!("BEGIN mismatch");
eprintln!("sqlite result: {sres:?}");
eprintln!("limbo result: {lres:?}");
let file = std::fs::File::create("fk_deferred.sql").unwrap();
for stmt in stmts.iter() {
writeln!(&file, "{stmt};").unwrap();
}
eprintln!("Wrote `tests/fk_deferred.sql` for debugging");
eprintln!("turso path: {}", limbo_db.path.display());
eprintln!("sqlite path: {}", sqlite_db.path.display());
panic!("BEGIN mismatch");
}
}
}
let op = rng.random_range(0..12);
let stmt = match op {
// Insert into child_deferred (can violate temporarily in transaction)
0 => {
let id = rng.random_range(1000..=2000);
let pid = if rng.random_bool(0.6) {
*parent_ids.iter().choose(&mut rng).unwrap_or(&1)
} else {
// Non-existent parent - OK if deferred and fixed before commit
rng.random_range(200..=300) as i64
};
let x = rng.random_range(-10..=10);
format!("INSERT INTO child_deferred VALUES ({id}, {pid}, {x})")
}
// Insert into child_immediate (must satisfy FK immediately)
1 => {
let id = rng.random_range(3000..=4000);
let pid = if rng.random_bool(0.8) {
*parent_ids.iter().choose(&mut rng).unwrap_or(&1)
} else {
rng.random_range(200..=300) as i64
};
let y = rng.random_range(-10..=10);
format!("INSERT INTO child_immediate VALUES ({id}, {pid}, {y})")
}
// Insert parent (may fix deferred violations)
2 => {
let id = rng.random_range(1..=300);
let a = rng.random_range(-5..=25);
let b = rng.random_range(-5..=25);
parent_ids.insert(id as i64);
format!("INSERT INTO parent VALUES ({id}, {a}, {b})")
}
// Delete parent (may cause violations)
3 => {
let id = if rng.random_bool(0.5) {
*parent_ids.iter().choose(&mut rng).unwrap_or(&1)
} else {
rng.random_range(1..=300) as i64
};
format!("DELETE FROM parent WHERE id={id}")
}
// Update parent PK
4 => {
let old = rng.random_range(1..=300);
let new = rng.random_range(1..=350);
format!("UPDATE parent SET id={new} WHERE id={old}")
}
// Update child_deferred FK
5 => {
let id = rng.random_range(1000..=2000);
let pid = if rng.random_bool(0.5) {
*parent_ids.iter().choose(&mut rng).unwrap_or(&1)
} else {
rng.random_range(200..=400) as i64
};
format!("UPDATE child_deferred SET pid={pid} WHERE id={id}")
}
// Insert into composite deferred child
6 => {
let id = rng.random_range(5000..=6000);
let (ca, cb) = if rng.random_bool(0.6) {
*comp_pairs.iter().choose(&mut rng).unwrap_or(&(1, 1))
} else {
// Non-existent composite parent
(
rng.random_range(-5..=8) as i64,
rng.random_range(-5..=8) as i64,
)
};
let z = rng.random_range(0..=10);
format!(
"INSERT INTO child_comp_deferred VALUES ({id}, {ca}, {cb}, {z}) ON CONFLICT DO NOTHING"
)
}
// Insert composite parent
7 => {
let a = rng.random_range(-5..=8) as i64;
let b = rng.random_range(-5..=8) as i64;
let c = rng.random_range(0..=20);
comp_pairs.insert((a, b));
format!("INSERT INTO parent_comp VALUES ({a}, {b}, {c})")
}
// UPSERT with deferred child
8 => {
let id = rng.random_range(1000..=2000);
let pid = if rng.random_bool(0.5) {
*parent_ids.iter().choose(&mut rng).unwrap_or(&1)
} else {
rng.random_range(200..=400) as i64
};
let x = rng.random_range(-10..=10);
format!(
"INSERT INTO child_deferred VALUES ({id}, {pid}, {x})
ON CONFLICT(id) DO UPDATE SET pid=excluded.pid, x=excluded.x"
)
}
// Delete from child_deferred
9 => {
let id = rng.random_range(1000..=2000);
format!("DELETE FROM child_deferred WHERE id={id}")
}
// Self-referential deferred insert (create temp violation then fix)
10 if use_transaction => {
let id = rng.random_range(400..=500);
let pid = id + 1; // References non-existent yet
format!("INSERT INTO child_deferred VALUES ({id}, {pid}, 0)")
}
_ => {
// Default: simple parent insert
let id = rng.random_range(1..=300);
format!("INSERT INTO parent VALUES ({id}, 0, 0)")
}
};
let stmt = log_and_exec(&stmt);
let sres = sqlite.execute(&stmt, params![]);
let lres = limbo_exec_rows_fallible(&limbo_db, &limbo, &stmt);
if !use_transaction && !in_tx {
match (sres, lres) {
(Ok(_), Ok(_)) | (Err(_), Err(_)) => {}
(s, l) => {
eprintln!("Non-tx mismatch: sqlite={s:?}, limbo={l:?}");
eprintln!("Statement: {stmt}");
eprintln!("Seed: {seed}, outer: {outer}, tx: {tx_num}");
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<usize>,
limbo_res: Result<Vec<Vec<rusqlite::types::Value>>, turso_core::LimboError>,
last_stmt: &str,
tag: &str,
) {
match (sqlite_res.is_ok(), limbo_res.is_ok()) {
(true, true) | (false, false) => (),
_ => {
eprintln!("\n=== {tag} mismatch ===");
eprintln!("seed: {seed}");
eprintln!("sqlite: {sqlite_res:?}, limbo: {limbo_res:?}");
eprintln!("stmt: {last_stmt}");
eprintln!("--- replay statements ({}) ---", stmts.len());
for (i, s) in stmts.iter().enumerate() {
eprintln!("{:04}: {};", i + 1, s);
}
panic!("{tag}: engines disagree");
}
}
}
// parent rowid, child textified integers -> MustBeInt coercion path
for outer in 0..OUTER_ITERS {
let limbo_db = TempDatabase::new_empty(true);
let sqlite_db = TempDatabase::new_empty(true);
let limbo = limbo_db.connect_limbo();
let sqlite = rusqlite::Connection::open(sqlite_db.path.clone()).unwrap();
let mut stmts: Vec<String> = Vec::new();
let log = |s: &str, stmts: &mut Vec<String>| {
stmts.push(s.to_string());
s.to_string()
};
for s in [
"PRAGMA foreign_keys=ON",
"CREATE TABLE p(id INTEGER PRIMARY KEY, a INT)",
"CREATE TABLE c(id INTEGER PRIMARY KEY, x INT, FOREIGN KEY(x) REFERENCES p(id))",
] {
let s = log(s, &mut stmts);
let _ = limbo_exec_rows_fallible(&limbo_db, &limbo, &s);
let _ = sqlite.execute(&s, params![]);
}
// Seed a few parents
for _ in 0..rng.random_range(2..=5) {
let id = rng.random_range(1..=15);
let a = rng.random_range(-5..=5);
let s = log(&format!("INSERT INTO p VALUES({id},{a})"), &mut stmts);
let _ = limbo_exec_rows_fallible(&limbo_db, &limbo, &s);
let _ = sqlite.execute(&s, params![]);
}
// try random child inserts with weird text-ints
for i in 0..INNER_ITERS {
let id = 1000 + i as i64;
let raw = if rng.random_bool(0.7) {
1 + rng.random_range(0..=15)
} else {
rng.random_range(100..=200) as i64
};
// Randomly decorate the integer as text with spacing/zeros/plus
let pad_left_zeros = rng.random_range(0..=2);
let spaces_left = rng.random_range(0..=2);
let spaces_right = rng.random_range(0..=2);
let plus = if rng.random_bool(0.3) { "+" } else { "" };
let txt_num = format!(
"{plus}{:0width$}",
raw,
width = (1 + pad_left_zeros) as usize
);
let txt = format!(
"'{}{}{}'",
" ".repeat(spaces_left),
txt_num,
" ".repeat(spaces_right)
);
let stmt = log(&format!("INSERT INTO c VALUES({id}, {txt})"), &mut stmts);
let sres = sqlite.execute(&stmt, params![]);
let lres = limbo_exec_rows_fallible(&limbo_db, &limbo, &stmt);
assert_parity(seed, &stmts, sres, lres, &stmt, "A: rowid-coercion");
}
println!("A {}/{} ok", outer + 1, OUTER_ITERS);
}
// slf-referential rowid FK
for outer in 0..OUTER_ITERS {
let limbo_db = TempDatabase::new_empty(true);
let sqlite_db = TempDatabase::new_empty(true);
let limbo = limbo_db.connect_limbo();
let sqlite = rusqlite::Connection::open(sqlite_db.path.clone()).unwrap();
let mut stmts: Vec<String> = Vec::new();
let log = |s: &str, stmts: &mut Vec<String>| {
stmts.push(s.to_string());
s.to_string()
};
for s in [
"PRAGMA foreign_keys=ON",
"CREATE TABLE t(id INTEGER PRIMARY KEY, rid REFERENCES t(id))",
] {
let s = log(s, &mut stmts);
limbo_exec_rows(&limbo_db, &limbo, &s);
sqlite.execute(&s, params![]).unwrap();
}
// Self-match should succeed for many ids
for _ in 0..INNER_ITERS {
let id = rng.random_range(1..=500);
let stmt = log(
&format!("INSERT INTO t(id,rid) VALUES({id},{id})"),
&mut stmts,
);
let sres = sqlite.execute(&stmt, params![]);
let lres = limbo_exec_rows_fallible(&limbo_db, &limbo, &stmt);
assert_parity(seed, &stmts, sres, lres, &stmt, "B1: self-row ok");
}
// Mismatch (rid != id) should fail (unless the referenced id already exists).
for _ in 0..rng.random_range(1..=10) {
let id = rng.random_range(1..=20);
let s = log(
&format!("INSERT INTO t(id,rid) VALUES({id},{id})"),
&mut stmts,
);
let s_res = sqlite.execute(&s, params![]);
let turso_rs = limbo_exec_rows_fallible(&limbo_db, &limbo, &s);
match (s_res.is_ok(), turso_rs.is_ok()) {
(true, true) | (false, false) => {}
_ => panic!("Seeding self-ref failed differently"),
}
}
for _ in 0..INNER_ITERS {
let id = rng.random_range(600..=900);
let ref_ = rng.random_range(1..=25);
let stmt = log(
&format!("INSERT INTO t(id,rid) VALUES({id},{ref_})"),
&mut stmts,
);
let sres = sqlite.execute(&stmt, params![]);
let lres = limbo_exec_rows_fallible(&limbo_db, &limbo, &stmt);
assert_parity(seed, &stmts, sres, lres, &stmt, "B2: self-row mismatch");
}
println!("B {}/{} ok", outer + 1, OUTER_ITERS);
}
// self-referential UNIQUE(u,v) parent (fast-path for composite)
for outer in 0..OUTER_ITERS {
let limbo_db = TempDatabase::new_empty(true);
let sqlite_db = TempDatabase::new_empty(true);
let limbo = limbo_db.connect_limbo();
let sqlite = rusqlite::Connection::open(sqlite_db.path.clone()).unwrap();
let mut stmts: Vec<String> = Vec::new();
let log = |s: &str, stmts: &mut Vec<String>| {
stmts.push(s.to_string());
s.to_string()
};
let s = log("PRAGMA foreign_keys=ON", &mut stmts);
limbo_exec_rows(&limbo_db, &limbo, &s);
sqlite.execute(&s, params![]).unwrap();
// Variant the schema a bit: TEXT/TEXT, NUMERIC/TEXT, etc.
let decls = [
("TEXT", "TEXT"),
("TEXT", "NUMERIC"),
("NUMERIC", "TEXT"),
("TEXT", "BLOB"),
];
let (tu, tv) = decls[rng.random_range(0..decls.len())];
let s = log(
&format!(
"CREATE TABLE sr(u {tu}, v {tv}, cu {tu}, cv {tv}, UNIQUE(u,v), \
FOREIGN KEY(cu,cv) REFERENCES sr(u,v))"
),
&mut stmts,
);
limbo_exec_rows(&limbo_db, &limbo, &s);
sqlite.execute(&s, params![]).unwrap();
// Self-matching composite rows should succeed
for _ in 0..INNER_ITERS {
// Random small tokens, possibly padded
let u = format!("U{}", rng.random_range(0..50));
let v = format!("V{}", rng.random_range(0..50));
let mut cu = u.clone();
let mut cv = v.clone();
// occasionally wrap child refs as blobs/text to stress coercion on parent index
if rng.random_bool(0.2) {
// child cv as hex blob of ascii v
let hex: String = v.bytes().map(|b| format!("{b:02X}")).collect();
cv = format!("x'{hex}'");
} else {
cu = format!("'{cu}'");
cv = format!("'{cv}'");
}
let stmt = log(
&format!("INSERT INTO sr(u,v,cu,cv) VALUES('{u}','{v}',{cu},{cv})"),
&mut stmts,
);
let sres = sqlite.execute(&stmt, params![]);
let lres = limbo_exec_rows_fallible(&limbo_db, &limbo, &stmt);
assert_parity(seed, &stmts, sres, lres, &stmt, "C1: self-UNIQUE ok");
}
// Non-self-match likely fails unless earlier rows happen to satisfy (u,v)
for _ in 0..INNER_ITERS {
let u = format!("U{}", rng.random_range(60..100));
let v = format!("V{}", rng.random_range(60..100));
let cu = format!("'U{}'", rng.random_range(0..40));
let cv = format!("'{}{}'", "V", rng.random_range(0..40));
let stmt = log(
&format!("INSERT INTO sr(u,v,cu,cv) VALUES('{u}','{v}',{cu},{cv})"),
&mut stmts,
);
let sres = sqlite.execute(&stmt, params![]);
let lres = limbo_exec_rows_fallible(&limbo_db, &limbo, &stmt);
assert_parity(seed, &stmts, sres, lres, &stmt, "C2: self-UNIQUE mismatch");
}
println!("C {}/{} ok", outer + 1, OUTER_ITERS);
}
// parent TEXT UNIQUE(u,v), child types differ; rely on parent-index affinities
for outer in 0..OUTER_ITERS {
let limbo_db = TempDatabase::new_empty(true);
let sqlite_db = TempDatabase::new_empty(true);
let limbo = limbo_db.connect_limbo();
let sqlite = rusqlite::Connection::open(sqlite_db.path.clone()).unwrap();
let mut stmts: Vec<String> = Vec::new();
let log = |s: &str, stmts: &mut Vec<String>| {
stmts.push(s.to_string());
s.to_string()
};
for s in [
"PRAGMA foreign_keys=ON",
"CREATE TABLE parent(u TEXT, v TEXT, UNIQUE(u,v))",
"CREATE TABLE child(id INTEGER PRIMARY KEY, cu INT, cv BLOB, \
FOREIGN KEY(cu,cv) REFERENCES parent(u,v))",
] {
let s = log(s, &mut stmts);
limbo_exec_rows(&limbo_db, &limbo, &s);
sqlite.execute(&s, params![]).unwrap();
}
for _ in 0..rng.random_range(3..=8) {
let u_raw = rng.random_range(0..=9);
let v_raw = rng.random_range(0..=9);
let u = if rng.random_bool(0.4) {
format!("+{u_raw}")
} else {
format!("{u_raw}")
};
let v = if rng.random_bool(0.5) {
format!("{v_raw:02}",)
} else {
format!("{v_raw}")
};
let s = log(
&format!("INSERT INTO parent VALUES('{u}','{v}')"),
&mut stmts,
);
let l_res = limbo_exec_rows_fallible(&limbo_db, &limbo, &s);
let s_res = sqlite.execute(&s, params![]);
match (s_res, l_res) {
(Ok(_), Ok(_)) | (Err(_), Err(_)) => {}
(x, y) => {
panic!("Parent seeding mismatch: sqlite {x:?}, limbo {y:?}");
}
}
}
for i in 0..INNER_ITERS {
let id = i as i64 + 1;
let u_txt = if rng.random_bool(0.7) {
format!("+{}", rng.random_range(0..=9))
} else {
format!("{}", rng.random_range(0..=9))
};
let v_txt = if rng.random_bool(0.5) {
format!("{:02}", rng.random_range(0..=9))
} else {
format!("{}", rng.random_range(0..=9))
};
// produce child literals that *look different* but should match under TEXT affinity
// cu uses integer-ish form of u; cv uses blob of ASCII v or quoted v randomly.
let cu = if let Ok(u_int) = u_txt.trim().trim_start_matches('+').parse::<i64>() {
if rng.random_bool(0.5) {
format!("{u_int}",)
} else {
format!("'{u_txt}'")
}
} else {
format!("'{u_txt}'")
};
let cv = if rng.random_bool(0.6) {
let hex: String = v_txt
.as_bytes()
.iter()
.map(|b| format!("{b:02X}"))
.collect();
format!("x'{hex}'")
} else {
format!("'{v_txt}'")
};
let stmt = log(
&format!("INSERT INTO child VALUES({id}, {cu}, {cv})"),
&mut stmts,
);
let sres = sqlite.execute(&stmt, params![]);
let lres = limbo_exec_rows_fallible(&limbo_db, &limbo, &stmt);
assert_parity(seed, &stmts, sres, lres, &stmt, "D1: parent-index affinity");
}
for i in 0..(INNER_ITERS / 3) {
let id = 10_000 + i as i64;
let cu = rng.random_range(0..=9);
let miss = rng.random_range(10..=19);
let stmt = log(
&format!("INSERT INTO child VALUES({id}, {cu}, x'{miss:02X}')"),
&mut stmts,
);
let sres = sqlite.execute(&stmt, params![]);
let lres = limbo_exec_rows_fallible(&limbo_db, &limbo, &stmt);
assert_parity(seed, &stmts, sres, lres, &stmt, "D2: parent-index negative");
}
println!("D {}/{} ok", outer + 1, OUTER_ITERS);
}
println!("fk_edgecases_minifuzz complete (seed {seed})");
}
#[test]
pub fn fk_composite_pk_mutation_fuzz() {
let _ = env_logger::try_init();
let (mut rng, seed) = rng_from_time();
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!(