use std::cell::RefCell;
use super::{FrankenError, Row, SqliteValue, drive, retry, retry_transient};
pub(super) struct EngineStatement<'conn> {
connection: &'conn fsqlite::Connection,
sql: String,
statement: RefCell<fsqlite::PreparedStatement<'conn>>,
}
impl<'conn> EngineStatement<'conn> {
pub(super) fn new(
connection: &'conn fsqlite::Connection,
sql: &str,
) -> Result<Self, FrankenError> {
let retry_safety = retry::ReplaySafety::new(sql);
let statement =
retry_transient(connection, &retry_safety, || drive(connection.prepare(sql)))?;
Ok(Self {
connection,
sql: sql.to_string(),
statement: RefCell::new(statement),
})
}
fn run<T>(
&self,
mut action: impl FnMut(&fsqlite::PreparedStatement<'conn>) -> Result<T, FrankenError>,
) -> Result<T, FrankenError> {
let retry_safety = retry::ReplaySafety::new(&self.sql);
let was_in_transaction = self.connection.in_transaction();
let result = retry_transient(self.connection, &retry_safety, || {
action(&self.statement.borrow())
})
.or_else(|error| {
if !super::schema_stale(&error)
|| was_in_transaction != self.connection.in_transaction()
|| !retry_safety.allows_replay()
{
return Err(error);
}
let refreshed = retry_transient(self.connection, &retry_safety, || {
drive(self.connection.prepare(&self.sql))
})?;
if was_in_transaction != self.connection.in_transaction() {
return Err(error);
}
drop(self.statement.replace(refreshed));
retry_transient(self.connection, &retry_safety, || {
action(&self.statement.borrow())
})
});
if matches!(&result, Err(FrankenError::BusyRecovery)) {
super::wal_index::warn_if_poisoned(self.connection.path());
}
result
}
pub(super) fn explain(&self) -> String {
self.statement.borrow().explain()
}
pub(super) fn query(&self) -> Result<Vec<Row>, FrankenError> {
self.run(|statement| drive(statement.query()))
}
pub(super) fn query_with_params(
&self,
params: &[SqliteValue],
) -> Result<Vec<Row>, FrankenError> {
self.run(|statement| drive(statement.query_with_params(params)))
}
pub(super) fn query_row(&self) -> Result<Row, FrankenError> {
self.run(|statement| drive(statement.query_row()))
}
pub(super) fn query_row_with_params(
&self,
params: &[SqliteValue],
) -> Result<Row, FrankenError> {
self.run(|statement| drive(statement.query_row_with_params(params)))
}
pub(super) fn execute(&self) -> Result<usize, FrankenError> {
self.run(|statement| drive(statement.execute()))
}
pub(super) fn execute_with_params(
&self,
params: &[SqliteValue],
) -> Result<usize, FrankenError> {
self.run(|statement| drive(statement.execute_with_params(params)))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::franken_sync::Connection;
#[test]
fn prepared_recovery_retries_without_duplicating_parameterized_writes() {
let conn = Connection::open(":memory:").unwrap();
conn.execute("CREATE TABLE t (value TEXT)").unwrap();
let statement = EngineStatement::new(conn.as_async(), "INSERT INTO t VALUES (?1)").unwrap();
let params = [SqliteValue::from("write exactly once")];
let mut attempts = 0;
let affected = statement
.run(|engine| {
attempts += 1;
if attempts == 1 {
return Err(FrankenError::BusyRecovery);
}
drive(engine.execute_with_params(¶ms))
})
.unwrap();
assert_eq!(attempts, 2);
assert_eq!(affected, 1);
let rows = conn.query("SELECT value FROM t").unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].values(), ¶ms);
}
#[test]
fn prepared_recovery_and_schema_refresh_keep_current_bindings() {
let conn = Connection::open(":memory:").unwrap();
let statement = EngineStatement::new(conn.as_async(), "SELECT ?1, ?2").unwrap();
let params = [
SqliteValue::from("current binding"),
SqliteValue::Integer(23),
];
let mut attempts = 0;
let row = statement
.run(|engine| {
attempts += 1;
match attempts {
1 => Err(FrankenError::BusyRecovery),
2 => Err(FrankenError::SchemaChanged),
_ => drive(engine.query_row_with_params(¶ms)),
}
})
.unwrap();
assert_eq!(attempts, 3);
assert_eq!(row.values(), ¶ms);
let next = [
SqliteValue::from("next invocation"),
SqliteValue::Integer(42),
];
assert_eq!(
statement.query_row_with_params(&next).unwrap().values(),
&next
);
}
#[test]
fn prepared_schema_refresh_replaces_the_compiled_projection() {
let conn = Connection::open(":memory:").unwrap();
conn.execute("CREATE TABLE t (value TEXT)").unwrap();
conn.execute("INSERT INTO t VALUES ('old')").unwrap();
let statement = EngineStatement::new(conn.as_async(), "SELECT * FROM t").unwrap();
assert_eq!(statement.query_row().unwrap().values().len(), 1);
conn.execute("DROP TABLE t").unwrap();
conn.execute("CREATE TABLE t (value TEXT, extra INTEGER)")
.unwrap();
conn.execute("INSERT INTO t VALUES ('new', 7)").unwrap();
let mut attempts = 0;
let row = statement
.run(|engine| {
attempts += 1;
if attempts == 1 {
return Err(FrankenError::SchemaChanged);
}
drive(engine.query_row())
})
.unwrap();
assert_eq!(attempts, 2);
assert_eq!(
row.values(),
&[SqliteValue::from("new"), SqliteValue::Integer(7)]
);
assert_eq!(statement.query_row().unwrap().values(), row.values());
}
#[test]
fn prepared_schema_refresh_is_bounded_and_preserves_real_resolution_errors() {
let conn = Connection::open(":memory:").unwrap();
conn.execute("CREATE TABLE t (value TEXT)").unwrap();
let statement = EngineStatement::new(conn.as_async(), "SELECT value FROM t").unwrap();
let mut attempts = 0;
let result: Result<(), FrankenError> = statement.run(|_| {
attempts += 1;
Err(FrankenError::SchemaChanged)
});
assert!(matches!(result, Err(FrankenError::SchemaChanged)));
assert_eq!(attempts, 2, "a persistent schema error must not spin");
conn.execute("DROP TABLE t").unwrap();
attempts = 0;
let result: Result<(), FrankenError> = statement.run(|_| {
attempts += 1;
Err(FrankenError::SchemaChanged)
});
assert!(matches!(result, Err(FrankenError::NoSuchTable { .. })));
assert_eq!(
attempts, 1,
"failed recompilation must not execute a stale plan"
);
}
#[test]
fn prepared_recovery_does_not_retry_lock_contention_or_corruption() {
let conn = Connection::open(":memory:").unwrap();
let statement = EngineStatement::new(conn.as_async(), "SELECT 1").unwrap();
for corruption in [false, true] {
let mut attempts = 0;
let result: Result<(), FrankenError> = statement.run(|_| {
attempts += 1;
if corruption {
Err(FrankenError::WalCorrupt {
detail: "retain the original corruption diagnostic".to_string(),
})
} else {
Err(FrankenError::Busy)
}
});
assert_eq!(attempts, 1);
if corruption {
assert!(matches!(
result,
Err(FrankenError::WalCorrupt { detail })
if detail == "retain the original corruption diagnostic"
));
} else {
assert!(matches!(result, Err(FrankenError::Busy)));
}
}
}
#[test]
fn prepared_recovery_never_replays_across_a_transaction_boundary() {
for explicit in [false, true] {
for stale_schema in [false, true] {
let conn = Connection::open(":memory:").unwrap();
conn.execute("CREATE TABLE t (value INTEGER)").unwrap();
let statement =
EngineStatement::new(conn.as_async(), "INSERT INTO t VALUES (1)").unwrap();
if explicit {
conn.execute("BEGIN IMMEDIATE").unwrap();
}
let mut attempts = 0;
let result = statement.run(|engine| {
attempts += 1;
if attempts == 1 {
conn.execute(if explicit {
"ROLLBACK"
} else {
"BEGIN IMMEDIATE"
})?;
return Err(if stale_schema {
FrankenError::SchemaChanged
} else {
FrankenError::BusyRecovery
});
}
drive(engine.execute())
});
assert!(matches!(
result,
Err(FrankenError::SchemaChanged | FrankenError::BusyRecovery)
));
assert_eq!(
attempts, 1,
"transaction-changing failures must reach the owner"
);
assert!(conn.query("SELECT value FROM t").unwrap().is_empty());
assert_eq!(conn.as_async().in_transaction(), !explicit);
if !explicit {
conn.execute("ROLLBACK").unwrap();
}
}
}
}
#[test]
fn prepared_public_paths_keep_bindings_and_explicit_transactions() {
let conn = Connection::open(":memory:").unwrap();
conn.execute("CREATE TABLE t (value INTEGER)").unwrap();
conn.execute("BEGIN IMMEDIATE").unwrap();
let first = conn.prepare("INSERT INTO t VALUES (1)").unwrap();
assert_eq!(first.execute().unwrap(), 1);
let second = conn.prepare("INSERT INTO t VALUES (?1)").unwrap();
assert_eq!(
second
.execute_with_params(&[SqliteValue::Integer(2)])
.unwrap(),
1
);
let query = conn.prepare("SELECT value FROM t ORDER BY value").unwrap();
assert_eq!(query.query().unwrap().len(), 2);
let bound = conn
.prepare("SELECT value FROM t WHERE value = ?1")
.unwrap();
let params = [SqliteValue::Integer(2)];
assert_eq!(
bound.query_with_params(¶ms).unwrap()[0].values(),
¶ms
);
assert_eq!(
bound.query_row_with_params(¶ms).unwrap().values(),
¶ms
);
let single = conn.prepare("SELECT value FROM t WHERE value = 1").unwrap();
assert_eq!(
single.query_row().unwrap().values(),
&[SqliteValue::Integer(1)]
);
assert!(conn.as_async().in_transaction());
conn.execute("ROLLBACK").unwrap();
assert!(query.query().unwrap().is_empty());
}
#[test]
fn prepared_maintenance_errors_cannot_replay_or_recompile_the_pragma() {
for stale_schema in [false, true] {
let conn = Connection::open(":memory:").unwrap();
let statement =
EngineStatement::new(conn.as_async(), "PRAGMA user_version = 42").unwrap();
conn.execute("PRAGMA user_version = 17").unwrap();
let mut calls = 0;
let result = statement.run(|_| {
calls += 1;
if calls == 1 {
Err(if stale_schema {
FrankenError::SchemaChanged
} else {
FrankenError::BusyRecovery
})
} else {
Ok(0usize)
}
});
assert!(matches!(
result,
Err(FrankenError::SchemaChanged | FrankenError::BusyRecovery)
));
assert_eq!(calls, 1);
assert_eq!(
conn.query_row("PRAGMA user_version").unwrap().values(),
&[SqliteValue::Integer(17)]
);
}
}
}