lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README | LICENSE

commit 2c472afa5479a895616eb50f238198a02f7dd54d
parent 18aa55cdd1504ca64c10dc77e9858cb9255d4f33
Author: triesap <tyson@radroots.org>
Date:   Fri,  6 Mar 2026 02:36:54 +0000

sql-wasm-core: cover embedded error paths

- add failpoints for open, tx, row, and export paths
- route embedded helpers through failpoint-aware wrappers
- extend embedded tests to assert error codes and lock failures
- tests: cargo check, cargo test

Diffstat:
Mcrates/sql-wasm-core/src/embedded.rs | 354++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
1 file changed, 296 insertions(+), 58 deletions(-)

diff --git a/crates/sql-wasm-core/src/embedded.rs b/crates/sql-wasm-core/src/embedded.rs @@ -11,6 +11,111 @@ const SAVEPOINT_BEGIN: &str = "savepoint radroots_schema_tx"; const SAVEPOINT_RELEASE: &str = "release savepoint radroots_schema_tx"; const SAVEPOINT_ROLLBACK: &str = "rollback to savepoint radroots_schema_tx"; +#[cfg(test)] +mod failpoints { + use std::sync::atomic::{AtomicUsize, Ordering}; + + #[derive(Clone, Copy)] + pub enum Point { + Open = 1 << 0, + BeginExecute = 1 << 1, + ReleaseExecute = 1 << 2, + ExportSerialize = 1 << 3, + EncodeRows = 1 << 4, + RowToJson = 1 << 5, + } + + static FLAGS: AtomicUsize = AtomicUsize::new(0); + + pub fn set(point: Point) { + FLAGS.fetch_or(point as usize, Ordering::SeqCst); + } + + pub fn take(point: Point) -> bool { + let mask = point as usize; + let prev = FLAGS.fetch_and(!mask, Ordering::SeqCst); + (prev & mask) != 0 + } + + pub fn clear() { + FLAGS.store(0, Ordering::SeqCst); + } +} + +#[cfg(test)] +fn forced_error() -> rusqlite::Error { + rusqlite::Error::InvalidParameterName("forced".to_string()) +} + +fn open_in_memory_with_failpoint() -> Result<Connection, rusqlite::Error> { + #[cfg(test)] + if failpoints::take(failpoints::Point::Open) { + return Err(forced_error()); + } + Connection::open_in_memory() +} + +fn execute_begin_savepoint(conn: &Connection) -> Result<(), SqlError> { + #[cfg(test)] + let result = if failpoints::take(failpoints::Point::BeginExecute) { + Err(forced_error()) + } else { + conn.execute(SAVEPOINT_BEGIN, []) + }; + #[cfg(not(test))] + let result = conn.execute(SAVEPOINT_BEGIN, []); + result.map(|_| ()).map_err(map_rusqlite) +} + +fn execute_release_savepoint(conn: &Connection) -> Result<(), SqlError> { + #[cfg(test)] + let result = if failpoints::take(failpoints::Point::ReleaseExecute) { + Err(forced_error()) + } else { + conn.execute(SAVEPOINT_RELEASE, []) + }; + #[cfg(not(test))] + let result = conn.execute(SAVEPOINT_RELEASE, []); + result.map(|_| ()).map_err(map_rusqlite) +} + +fn serialize_main(conn: &Connection) -> Result<Vec<u8>, rusqlite::Error> { + #[cfg(test)] + if failpoints::take(failpoints::Point::ExportSerialize) { + return Err(forced_error()); + } + conn.serialize(DatabaseName::Main).map(|data| data.to_vec()) +} + +fn map_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Value> { + #[cfg(test)] + if failpoints::take(failpoints::Point::RowToJson) { + return Err(forced_error()); + } + sqlite_util::row_to_json(row) +} + +fn encode_rows(rows: &[Value]) -> Result<String, SqlError> { + #[cfg(test)] + if failpoints::take(failpoints::Point::EncodeRows) { + return serde_json::to_string(&FailSerialize).map_err(SqlError::from); + } + serde_json::to_string(rows).map_err(SqlError::from) +} + +#[cfg(test)] +struct FailSerialize; + +#[cfg(test)] +impl serde::Serialize for FailSerialize { + fn serialize<S>(&self, _serializer: S) -> Result<S::Ok, S::Error> + where + S: serde::Serializer, + { + Err(serde::ser::Error::custom("forced")) + } +} + #[derive(Debug)] pub struct EmbeddedSqlEngine { conn: Mutex<Connection>, @@ -18,7 +123,7 @@ pub struct EmbeddedSqlEngine { impl EmbeddedSqlEngine { pub fn new() -> Result<Self, SqlError> { - let conn = Connection::open_in_memory().map_err(map_rusqlite)?; + let conn = open_in_memory_with_failpoint().map_err(map_rusqlite)?; Ok(Self { conn: Mutex::new(conn), }) @@ -43,7 +148,7 @@ impl EmbeddedSqlEngine { let conn = self.conn.lock().map_err(|_| SqlError::Internal)?; let mut stmt = conn.prepare(sql).map_err(map_rusqlite)?; let params = params_from_iter(binds.into_iter()); - let mapped = stmt.query_map(params, sqlite_util::row_to_json)?; + let mapped = stmt.query_map(params, map_row)?; mapped .collect::<Result<Vec<_>, _>>() .map_err(map_rusqlite)? @@ -53,32 +158,29 @@ impl EmbeddedSqlEngine { pub fn query_raw(&self, sql: &str, params_json: &str) -> Result<String, SqlError> { let rows = self.query_rows(sql, params_json)?; - serde_json::to_string(&rows).map_err(SqlError::from) + encode_rows(&rows) } pub fn begin_tx(&self) -> Result<(), SqlError> { let conn = self.conn.lock().map_err(|_| SqlError::Internal)?; - conn.execute(SAVEPOINT_BEGIN, []).map_err(map_rusqlite)?; - Ok(()) + execute_begin_savepoint(&conn) } pub fn commit_tx(&self) -> Result<(), SqlError> { let conn = self.conn.lock().map_err(|_| SqlError::Internal)?; - conn.execute(SAVEPOINT_RELEASE, []).map_err(map_rusqlite)?; - Ok(()) + execute_release_savepoint(&conn) } pub fn rollback_tx(&self) -> Result<(), SqlError> { let conn = self.conn.lock().map_err(|_| SqlError::Internal)?; conn.execute(SAVEPOINT_ROLLBACK, []).map_err(map_rusqlite)?; - conn.execute(SAVEPOINT_RELEASE, []).map_err(map_rusqlite)?; - Ok(()) + execute_release_savepoint(&conn) } pub fn export_bytes(&self) -> Result<Vec<u8>, SqlError> { let conn = self.conn.lock().map_err(|_| SqlError::Internal)?; - let data = conn.serialize(DatabaseName::Main).map_err(map_rusqlite)?; - Ok(data.to_vec()) + let data = serialize_main(&conn).map_err(map_rusqlite)?; + Ok(data) } } @@ -114,91 +216,227 @@ pub fn coverage_branch_probe(input: bool) -> &'static str { #[cfg(all(test, feature = "embedded"))] mod tests { - use super::{EmbeddedSqlEngine, coverage_branch_probe}; + use super::{EmbeddedSqlEngine, coverage_branch_probe, failpoints}; use radroots_sql_core::{SqlError, SqlExecutor}; const CREATE_TABLE_SQL: &str = "CREATE TABLE test_items (id INTEGER PRIMARY KEY, name TEXT)"; + fn poison_engine(engine: &EmbeddedSqlEngine) { + let _ = std::panic::catch_unwind(|| { + let _guard = engine.conn.lock().unwrap(); + panic!("poison"); + }); + } + #[test] - fn exec_query_roundtrip() -> Result<(), SqlError> { - let engine = EmbeddedSqlEngine::new()?; - engine.exec(CREATE_TABLE_SQL, "[]")?; - let outcome = engine.exec("INSERT INTO test_items (name) VALUES (?)", "[\"rad\"]")?; + fn open_in_memory_failpoint_surfaces_error() { + failpoints::clear(); + failpoints::set(failpoints::Point::Open); + let err = EmbeddedSqlEngine::new().unwrap_err(); + assert!(matches!(err, SqlError::InvalidQuery(_))); + } + + #[test] + fn exec_query_roundtrip() { + let engine = EmbeddedSqlEngine::new().unwrap(); + engine.exec(CREATE_TABLE_SQL, "[]").unwrap(); + let outcome = engine + .exec("INSERT INTO test_items (name) VALUES (?)", "[\"rad\"]") + .unwrap(); assert_eq!(outcome.changes, 1); - let rows = engine.query_rows("SELECT name FROM test_items WHERE id = ?", "[1]")?; + let rows = engine + .query_rows("SELECT name FROM test_items WHERE id = ?", "[1]") + .unwrap(); let name = rows .first() .and_then(|row| row.get("name")) .and_then(|value| value.as_str()) - .ok_or(SqlError::InvalidArgument("missing name".to_string()))?; + .expect("missing name"); assert_eq!(name, "rad"); - Ok(()) } #[test] - fn rollback_discards_changes() -> Result<(), SqlError> { - let engine = EmbeddedSqlEngine::new()?; - engine.exec(CREATE_TABLE_SQL, "[]")?; - engine.begin_tx()?; - engine.exec("INSERT INTO test_items (name) VALUES (?)", "[\"rad\"]")?; - engine.rollback_tx()?; - let rows = engine.query_rows("SELECT name FROM test_items", "[]")?; + fn rollback_discards_changes() { + let engine = EmbeddedSqlEngine::new().unwrap(); + engine.exec(CREATE_TABLE_SQL, "[]").unwrap(); + engine.begin_tx().unwrap(); + engine + .exec("INSERT INTO test_items (name) VALUES (?)", "[\"rad\"]") + .unwrap(); + engine.rollback_tx().unwrap(); + let rows = engine.query_rows("SELECT name FROM test_items", "[]").unwrap(); assert!(rows.is_empty()); - Ok(()) } #[test] - fn export_bytes_non_empty() -> Result<(), SqlError> { - let engine = EmbeddedSqlEngine::new()?; - engine.exec(CREATE_TABLE_SQL, "[]")?; - engine.exec("INSERT INTO test_items (name) VALUES (?)", "[\"rad\"]")?; - let bytes = engine.export_bytes()?; + fn export_bytes_non_empty() { + let engine = EmbeddedSqlEngine::new().unwrap(); + engine.exec(CREATE_TABLE_SQL, "[]").unwrap(); + engine + .exec("INSERT INTO test_items (name) VALUES (?)", "[\"rad\"]") + .unwrap(); + let bytes = engine.export_bytes().unwrap(); assert!(!bytes.is_empty()); - Ok(()) } #[test] - fn query_raw_commit_and_trait_executor_paths() -> Result<(), SqlError> { - let engine = EmbeddedSqlEngine::new()?; - engine.exec(CREATE_TABLE_SQL, "[]")?; - engine.begin_tx()?; - engine.exec("INSERT INTO test_items (name) VALUES (?)", "[\"rad\"]")?; - engine.commit_tx()?; - let rows = engine.query_raw("SELECT name FROM test_items ORDER BY id ASC", "[]")?; + fn query_raw_commit_and_trait_executor_paths() { + let engine = EmbeddedSqlEngine::new().unwrap(); + engine.exec(CREATE_TABLE_SQL, "[]").unwrap(); + engine.begin_tx().unwrap(); + engine + .exec("INSERT INTO test_items (name) VALUES (?)", "[\"rad\"]") + .unwrap(); + engine.commit_tx().unwrap(); + let rows = engine + .query_raw("SELECT name FROM test_items ORDER BY id ASC", "[]") + .unwrap(); assert!(rows.contains("rad")); let executor: &dyn SqlExecutor = &engine; - executor.begin()?; - let _ = executor.exec("INSERT INTO test_items (name) VALUES (?)", "[\"trait\"]")?; - executor.rollback()?; - let rows_after = executor.query_raw("SELECT name FROM test_items ORDER BY id ASC", "[]")?; + executor.begin().unwrap(); + let _ = executor + .exec("INSERT INTO test_items (name) VALUES (?)", "[\"trait\"]") + .unwrap(); + executor.rollback().unwrap(); + let rows_after = executor + .query_raw("SELECT name FROM test_items ORDER BY id ASC", "[]") + .unwrap(); assert!(rows_after.contains("rad")); assert!(!rows_after.contains("trait")); - Ok(()) } #[test] - fn invalid_sql_paths_surface_invalid_query() -> Result<(), SqlError> { - let engine = EmbeddedSqlEngine::new()?; - let err_exec = engine.exec("INSERT INTO missing (name) VALUES (?)", "[\"rad\"]"); - assert!(matches!(err_exec, Err(SqlError::InvalidQuery(_)))); + fn invalid_sql_paths_surface_invalid_query() { + let engine = EmbeddedSqlEngine::new().unwrap(); + let err_exec = engine + .exec("INSERT INTO missing (name) VALUES (?)", "[\"rad\"]") + .unwrap_err(); + assert_eq!(err_exec.code(), "ERR_INVALID_QUERY"); let err_rows = engine.query_rows("SELECT name FROM missing", "[]"); - assert!(matches!(err_rows, Err(SqlError::InvalidQuery(_)))); + assert_eq!(err_rows.unwrap_err().code(), "ERR_INVALID_QUERY"); let err_raw = engine.query_raw("SELECT name FROM missing", "[]"); - assert!(matches!(err_raw, Err(SqlError::InvalidQuery(_)))); + assert_eq!(err_raw.unwrap_err().code(), "ERR_INVALID_QUERY"); - let err_commit = engine.commit_tx(); - assert!(matches!(err_commit, Err(SqlError::InvalidQuery(_)))); + let err_commit = engine.commit_tx().unwrap_err(); + assert_eq!(err_commit.code(), "ERR_INVALID_QUERY"); - let err_rollback = engine.rollback_tx(); - assert!(matches!(err_rollback, Err(SqlError::InvalidQuery(_)))); + let err_rollback = engine.rollback_tx().unwrap_err(); + assert_eq!(err_rollback.code(), "ERR_INVALID_QUERY"); let executor: &dyn SqlExecutor = &engine; - let err_trait_commit = executor.commit(); - assert!(matches!(err_trait_commit, Err(SqlError::InvalidQuery(_)))); - Ok(()) + let err_trait_commit = executor.commit().unwrap_err(); + assert_eq!(err_trait_commit.code(), "ERR_INVALID_QUERY"); + } + + #[test] + fn invalid_params_surface_errors() { + let engine = EmbeddedSqlEngine::new().unwrap(); + let err_exec = engine.exec(CREATE_TABLE_SQL, "{}").unwrap_err(); + assert_eq!(err_exec.code(), "ERR_SERIALIZATION"); + + let err_rows = engine.query_rows("SELECT 1", "{}").unwrap_err(); + assert_eq!(err_rows.code(), "ERR_SERIALIZATION"); + + let err_raw = engine.query_raw("SELECT 1", "{}").unwrap_err(); + assert_eq!(err_raw.code(), "ERR_SERIALIZATION"); + } + + #[test] + fn query_rows_surfaces_prepare_and_bind_errors() { + let engine = EmbeddedSqlEngine::new().unwrap(); + let err_prepare = engine + .query_rows("SELEC name FROM test_items", "[]") + .unwrap_err(); + assert_eq!(err_prepare.code(), "ERR_INVALID_QUERY"); + + engine.exec(CREATE_TABLE_SQL, "[]").unwrap(); + let err_bind = engine + .query_rows("SELECT name FROM test_items WHERE id = ?", "[]") + .unwrap_err(); + assert_eq!(err_bind.code(), "ERR_INVALID_QUERY"); + } + + #[test] + fn query_rows_collect_error_is_reported() { + let engine = EmbeddedSqlEngine::new().unwrap(); + engine.exec(CREATE_TABLE_SQL, "[]").unwrap(); + engine + .exec("INSERT INTO test_items (name) VALUES (?)", "[\"rad\"]") + .unwrap(); + failpoints::clear(); + failpoints::set(failpoints::Point::RowToJson); + let err = engine + .query_rows("SELECT name FROM test_items", "[]") + .unwrap_err(); + assert_eq!(err.code(), "ERR_INVALID_QUERY"); + } + + #[test] + fn query_raw_serialization_error_is_reported() { + let engine = EmbeddedSqlEngine::new().unwrap(); + engine.exec(CREATE_TABLE_SQL, "[]").unwrap(); + engine + .exec("INSERT INTO test_items (name) VALUES (?)", "[\"rad\"]") + .unwrap(); + failpoints::clear(); + failpoints::set(failpoints::Point::EncodeRows); + let err = engine + .query_raw("SELECT name FROM test_items", "[]") + .unwrap_err(); + assert_eq!(err.code(), "ERR_SERIALIZATION"); + } + + #[test] + fn begin_tx_failpoint_surfaces_error() { + let engine = EmbeddedSqlEngine::new().unwrap(); + failpoints::clear(); + failpoints::set(failpoints::Point::BeginExecute); + let err = engine.begin_tx().unwrap_err(); + assert_eq!(err.code(), "ERR_INVALID_QUERY"); + } + + #[test] + fn rollback_release_failpoint_surfaces_error() { + let engine = EmbeddedSqlEngine::new().unwrap(); + engine.exec(CREATE_TABLE_SQL, "[]").unwrap(); + engine.begin_tx().unwrap(); + failpoints::clear(); + failpoints::set(failpoints::Point::ReleaseExecute); + let err = engine.rollback_tx().unwrap_err(); + assert_eq!(err.code(), "ERR_INVALID_QUERY"); + } + + #[test] + fn export_bytes_failpoint_surfaces_error() { + let engine = EmbeddedSqlEngine::new().unwrap(); + engine.exec(CREATE_TABLE_SQL, "[]").unwrap(); + failpoints::clear(); + failpoints::set(failpoints::Point::ExportSerialize); + let err = engine.export_bytes().unwrap_err(); + assert_eq!(err.code(), "ERR_INVALID_QUERY"); + } + + #[test] + fn lock_errors_surface_internal() { + let engine = EmbeddedSqlEngine::new().unwrap(); + poison_engine(&engine); + let err_exec = engine.exec(CREATE_TABLE_SQL, "[]"); + assert_eq!(err_exec.unwrap_err().code(), "ERR_INTERNAL"); + let err_rows = engine.query_rows("SELECT 1", "[]"); + assert_eq!(err_rows.unwrap_err().code(), "ERR_INTERNAL"); + let err_raw = engine.query_raw("SELECT 1", "[]"); + assert_eq!(err_raw.unwrap_err().code(), "ERR_INTERNAL"); + let err_begin = engine.begin_tx(); + assert_eq!(err_begin.unwrap_err().code(), "ERR_INTERNAL"); + let err_commit = engine.commit_tx(); + assert_eq!(err_commit.unwrap_err().code(), "ERR_INTERNAL"); + let err_rollback = engine.rollback_tx(); + assert_eq!(err_rollback.unwrap_err().code(), "ERR_INTERNAL"); + let err_export = engine.export_bytes(); + assert_eq!(err_export.unwrap_err().code(), "ERR_INTERNAL"); } #[test]