lib

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

commit a92e17315695e9bc911725d863ca4668509550dd
parent d08255a21aff993fd6f0808304204e8401bcbc67
Author: triesap <tyson@radroots.org>
Date:   Sun, 18 Jan 2026 22:04:44 +0000

sql-wasm-core: implement embedded sqlite engine


- add embedded engine with savepoint transactions and serialization export
- route wasm exec/query/export bindings through embedded engine when enabled
- share sqlite param and row mapping helpers via `radroots-sql-core`
- add embedded engine unit tests for exec/query/export

Diffstat:
MCargo.lock | 1+
Msql-core/src/executor_sqlite.rs | 64++++++----------------------------------------------------------
Msql-core/src/lib.rs | 2++
Asql-core/src/sqlite_util.rs | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msql-wasm-core/Cargo.toml | 3++-
Msql-wasm-core/src/embedded.rs | 169+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Msql-wasm-core/src/lib.rs | 97++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
7 files changed, 324 insertions(+), 71 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -1878,6 +1878,7 @@ dependencies = [ "js-sys", "radroots-sql-core", "radroots-sql-wasm-bridge", + "rusqlite", "serde", "serde-wasm-bindgen", "serde_json", diff --git a/sql-core/src/executor_sqlite.rs b/sql-core/src/executor_sqlite.rs @@ -1,6 +1,7 @@ use crate::{ExecOutcome, SqlExecutor, error::SqlError}; -use rusqlite::{Connection, Row, params_from_iter}; -use serde_json::{Map, Value}; +use crate::sqlite_util; +use rusqlite::{Connection, params_from_iter}; +use serde_json::Value; use std::path::Path; use std::sync::{Arc, Mutex}; @@ -23,64 +24,11 @@ impl SqliteExecutor { }) } - fn parse_params(&self, params_json: &str) -> Result<Vec<rusqlite::types::Value>, SqlError> { - let vals: Vec<Value> = serde_json::from_str(params_json) - .map_err(|e| SqlError::SerializationError(e.to_string()))?; - vals.into_iter() - .map(|v| match v { - Value::Null => Ok(rusqlite::types::Value::Null), - Value::Bool(b) => Ok(rusqlite::types::Value::from(if b { 1 } else { 0 })), - Value::Number(n) => { - if let Some(i) = n.as_i64() { - Ok(rusqlite::types::Value::from(i)) - } else if let Some(u) = n.as_u64() { - Ok(rusqlite::types::Value::from(u as i64)) - } else if let Some(f) = n.as_f64() { - Ok(rusqlite::types::Value::from(f)) - } else { - Err(SqlError::InvalidArgument("unsupported number".to_string())) - } - } - Value::String(s) => Ok(rusqlite::types::Value::from(s)), - other => Err(SqlError::InvalidArgument(format!( - "unsupported bind value: {}", - other - ))), - }) - .collect() - } - - fn row_to_json(row: &Row) -> rusqlite::Result<Value> { - let stmt = row.as_ref(); - let mut obj = Map::new(); - for i in 0..stmt.column_count() { - let name = stmt.column_name(i).unwrap_or("").to_string(); - let v = row.get_ref(i)?; - let j = match v { - rusqlite::types::ValueRef::Null => Value::Null, - rusqlite::types::ValueRef::Integer(i) => Value::from(i), - rusqlite::types::ValueRef::Real(f) => Value::from(f), - rusqlite::types::ValueRef::Text(s) => { - let s = std::str::from_utf8(s).map_err(|e| { - rusqlite::Error::FromSqlConversionFailure( - i, - rusqlite::types::Type::Text, - Box::new(e), - ) - })?; - Value::from(s.to_string()) - } - rusqlite::types::ValueRef::Blob(_) => Value::Null, - }; - obj.insert(name, j); - } - Ok(Value::Object(obj)) - } } impl SqlExecutor for SqliteExecutor { fn exec(&self, sql: &str, params_json: &str) -> Result<ExecOutcome, SqlError> { - let binds = self.parse_params(params_json)?; + let binds = sqlite_util::parse_params(params_json)?; let conn = self.conn.lock().map_err(|_| SqlError::Internal)?; let n = conn .execute(sql, params_from_iter(binds.into_iter())) @@ -93,12 +41,12 @@ impl SqlExecutor for SqliteExecutor { } fn query_raw(&self, sql: &str, params_json: &str) -> Result<String, SqlError> { - let binds = self.parse_params(params_json)?; + let binds = sqlite_util::parse_params(params_json)?; let rows = { let conn = self.conn.lock().map_err(|_| SqlError::Internal)?; let mut stmt = conn.prepare(sql).map_err(SqlError::from)?; let mapped = stmt.query_map(params_from_iter(binds.into_iter()), |row| { - Self::row_to_json(row) + sqlite_util::row_to_json(row) })?; let collected = mapped.collect::<Result<Vec<_>, _>>()?; collected diff --git a/sql-core/src/lib.rs b/sql-core/src/lib.rs @@ -17,6 +17,8 @@ pub use executor_wasm::{export_lock_active, export_lock_begin, export_lock_end, mod executor_sqlite; #[cfg(feature = "native")] pub use executor_sqlite::SqliteExecutor; +#[cfg(feature = "native")] +pub mod sqlite_util; #[cfg(feature = "embedded")] mod executor_embedded; diff --git a/sql-core/src/sqlite_util.rs b/sql-core/src/sqlite_util.rs @@ -0,0 +1,59 @@ +#![forbid(unsafe_code)] + +use crate::error::SqlError; +use rusqlite::{Row, types::Value as SqlValue}; +use serde_json::{Map, Value}; + +pub fn parse_params(params_json: &str) -> Result<Vec<SqlValue>, SqlError> { + let vals: Vec<Value> = serde_json::from_str(params_json) + .map_err(|e| SqlError::SerializationError(e.to_string()))?; + vals.into_iter() + .map(|v| match v { + Value::Null => Ok(SqlValue::Null), + Value::Bool(b) => Ok(SqlValue::from(if b { 1 } else { 0 })), + Value::Number(n) => { + if let Some(i) = n.as_i64() { + Ok(SqlValue::from(i)) + } else if let Some(u) = n.as_u64() { + Ok(SqlValue::from(u as i64)) + } else if let Some(f) = n.as_f64() { + Ok(SqlValue::from(f)) + } else { + Err(SqlError::InvalidArgument("unsupported number".to_string())) + } + } + Value::String(s) => Ok(SqlValue::from(s)), + other => Err(SqlError::InvalidArgument(format!( + "unsupported bind value: {}", + other + ))), + }) + .collect() +} + +pub fn row_to_json(row: &Row) -> rusqlite::Result<Value> { + let stmt = row.as_ref(); + let mut obj = Map::new(); + for i in 0..stmt.column_count() { + let name = stmt.column_name(i).unwrap_or("").to_string(); + let v = row.get_ref(i)?; + let j = match v { + rusqlite::types::ValueRef::Null => Value::Null, + rusqlite::types::ValueRef::Integer(i) => Value::from(i), + rusqlite::types::ValueRef::Real(f) => Value::from(f), + rusqlite::types::ValueRef::Text(s) => { + let s = std::str::from_utf8(s).map_err(|e| { + rusqlite::Error::FromSqlConversionFailure( + i, + rusqlite::types::Type::Text, + Box::new(e), + ) + })?; + Value::from(s.to_string()) + } + rusqlite::types::ValueRef::Blob(_) => Value::Null, + }; + obj.insert(name, j); + } + Ok(Value::Object(obj)) +} diff --git a/sql-wasm-core/Cargo.toml b/sql-wasm-core/Cargo.toml @@ -12,11 +12,12 @@ crate-type = ["cdylib", "rlib"] [features] default = ["bridge"] bridge = ["dep:radroots-sql-wasm-bridge"] -embedded = [] +embedded = ["dep:rusqlite", "radroots-sql-core/native"] [dependencies] radroots-sql-core = { workspace = true } radroots-sql-wasm-bridge = { workspace = true, optional = true } +rusqlite = { workspace = true, features = ["bundled", "serialize"], optional = true } chrono = { workspace = true, features = ["serde"] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } diff --git a/sql-wasm-core/src/embedded.rs b/sql-wasm-core/src/embedded.rs @@ -1,12 +1,173 @@ #![forbid(unsafe_code)] -use radroots_sql_core::error::SqlError; +use std::sync::Mutex; -#[derive(Clone, Copy, Debug, Default)] -pub struct EmbeddedSqlEngine; +use radroots_sql_core::sqlite_util; +use radroots_sql_core::{ExecOutcome, SqlError, SqlExecutor}; +use rusqlite::{Connection, DatabaseName, params_from_iter}; +use serde_json::Value; + +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"; + +#[derive(Debug)] +pub struct EmbeddedSqlEngine { + conn: Mutex<Connection>, +} impl EmbeddedSqlEngine { pub fn new() -> Result<Self, SqlError> { - Err(SqlError::UnsupportedPlatform) + let conn = Connection::open_in_memory().map_err(map_rusqlite)?; + Ok(Self { + conn: Mutex::new(conn), + }) + } + + pub fn exec(&self, sql: &str, params_json: &str) -> Result<ExecOutcome, SqlError> { + let binds = sqlite_util::parse_params(params_json)?; + let conn = self.conn.lock().map_err(|_| SqlError::Internal)?; + let changes = conn + .execute(sql, params_from_iter(binds.into_iter())) + .map_err(map_rusqlite)?; + let last_insert_id = conn.last_insert_rowid(); + Ok(ExecOutcome { + changes: changes as i64, + last_insert_id, + }) + } + + pub fn query_rows(&self, sql: &str, params_json: &str) -> Result<Vec<Value>, SqlError> { + let binds = sqlite_util::parse_params(params_json)?; + let rows = { + let conn = self.conn.lock().map_err(|_| SqlError::Internal)?; + let mut stmt = conn.prepare(sql).map_err(map_rusqlite)?; + let mapped = stmt.query_map(params_from_iter(binds.into_iter()), sqlite_util::row_to_json)?; + mapped + .collect::<Result<Vec<_>, _>>() + .map_err(map_rusqlite)? + }; + Ok(rows) + } + + 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) + } + + 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(()) + } + + 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(()) + } + + 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(()) + } + + 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()) + } +} + +impl SqlExecutor for EmbeddedSqlEngine { + fn exec(&self, sql: &str, params_json: &str) -> Result<ExecOutcome, SqlError> { + EmbeddedSqlEngine::exec(self, sql, params_json) + } + + fn query_raw(&self, sql: &str, params_json: &str) -> Result<String, SqlError> { + EmbeddedSqlEngine::query_raw(self, sql, params_json) + } + + fn begin(&self) -> Result<(), SqlError> { + EmbeddedSqlEngine::begin_tx(self) + } + + fn commit(&self) -> Result<(), SqlError> { + EmbeddedSqlEngine::commit_tx(self) + } + + fn rollback(&self) -> Result<(), SqlError> { + EmbeddedSqlEngine::rollback_tx(self) + } +} + +fn map_rusqlite(err: rusqlite::Error) -> SqlError { + SqlError::InvalidQuery(err.to_string()) +} + +#[cfg(all(test, feature = "embedded"))] +mod tests { + use super::EmbeddedSqlEngine; + use radroots_sql_core::SqlError; + + #[test] + fn exec_query_roundtrip() -> Result<(), SqlError> { + let engine = EmbeddedSqlEngine::new()?; + engine.exec( + "CREATE TABLE test_items (id INTEGER PRIMARY KEY, name TEXT)", + "[]", + )?; + let outcome = engine.exec( + "INSERT INTO test_items (name) VALUES (?)", + "[\"rad\"]", + )?; + assert_eq!(outcome.changes, 1); + let rows = engine.query_rows( + "SELECT name FROM test_items WHERE id = ?", + "[1]", + )?; + let name = rows + .first() + .and_then(|row| row.get("name")) + .and_then(|value| value.as_str()) + .ok_or_else(|| SqlError::InvalidArgument("missing name".to_string()))?; + assert_eq!(name, "rad"); + Ok(()) + } + + #[test] + fn rollback_discards_changes() -> Result<(), SqlError> { + let engine = EmbeddedSqlEngine::new()?; + engine.exec( + "CREATE TABLE test_items (id INTEGER PRIMARY KEY, name TEXT)", + "[]", + )?; + 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", "[]")?; + assert!(rows.is_empty()); + Ok(()) + } + + #[test] + fn export_bytes_non_empty() -> Result<(), SqlError> { + let engine = EmbeddedSqlEngine::new()?; + engine.exec( + "CREATE TABLE test_items (id INTEGER PRIMARY KEY, name TEXT)", + "[]", + )?; + engine.exec( + "INSERT INTO test_items (name) VALUES (?)", + "[\"rad\"]", + )?; + let bytes = engine.export_bytes()?; + assert!(!bytes.is_empty()); + Ok(()) } } diff --git a/sql-wasm-core/src/lib.rs b/sql-wasm-core/src/lib.rs @@ -7,14 +7,19 @@ use radroots_sql_core::utils; #[cfg(target_arch = "wasm32")] use serde::de::DeserializeOwned; +#[cfg(all(feature = "embedded", target_arch = "wasm32"))] +use js_sys::Uint8Array; +#[cfg(all(feature = "embedded", target_arch = "wasm32"))] +use std::sync::OnceLock; + #[cfg(target_arch = "wasm32")] use wasm_bindgen::JsValue; #[cfg(target_arch = "wasm32")] use wasm_bindgen::prelude::*; -#[cfg(all(feature = "embedded", target_arch = "wasm32"))] +#[cfg(feature = "embedded")] mod embedded; -#[cfg(all(feature = "embedded", target_arch = "wasm32"))] +#[cfg(feature = "embedded")] pub use embedded::EmbeddedSqlEngine; #[cfg(target_arch = "wasm32")] @@ -31,36 +36,112 @@ pub fn err_js(err: SqlError) -> JsValue { } } -#[cfg(all(feature = "bridge", target_arch = "wasm32"))] +#[cfg(all(feature = "embedded", target_arch = "wasm32"))] +fn embedded_engine() -> Result<&'static EmbeddedSqlEngine, SqlError> { + static ENGINE: OnceLock<EmbeddedSqlEngine> = OnceLock::new(); + if let Some(engine) = ENGINE.get() { + return Ok(engine); + } + let engine = EmbeddedSqlEngine::new()?; + let _ = ENGINE.set(engine); + ENGINE.get().ok_or(SqlError::Internal) +} + +#[cfg(all(feature = "embedded", target_arch = "wasm32"))] +#[wasm_bindgen(js_name = exec_sql)] +pub fn exec_sql(sql: &str, params_json: &str) -> JsValue { + let outcome = match embedded_engine().and_then(|engine| engine.exec(sql, params_json)) { + Ok(outcome) => outcome, + Err(err) => return err_js(err), + }; + let payload = serde_json::json!({ + "changes": outcome.changes, + "last_insert_id": outcome.last_insert_id, + "lastInsertRowid": outcome.last_insert_id, + }); + match serde_wasm_bindgen::to_value(&payload) { + Ok(value) => value, + Err(err) => err_js(SqlError::SerializationError(err.to_string())), + } +} + +#[cfg(all(feature = "embedded", target_arch = "wasm32"))] +#[wasm_bindgen(js_name = query_sql)] +pub fn query_sql(sql: &str, params_json: &str) -> JsValue { + let rows = match embedded_engine().and_then(|engine| engine.query_rows(sql, params_json)) { + Ok(rows) => rows, + Err(err) => return err_js(err), + }; + match serde_wasm_bindgen::to_value(&rows) { + Ok(value) => value, + Err(err) => err_js(SqlError::SerializationError(err.to_string())), + } +} + +#[cfg(all(feature = "embedded", target_arch = "wasm32"))] +pub fn export_bytes() -> JsValue { + let bytes = match embedded_engine().and_then(|engine| engine.export_bytes()) { + Ok(bytes) => bytes, + Err(err) => return err_js(err), + }; + let array = Uint8Array::from(bytes.as_slice()); + JsValue::from(array) +} + +#[cfg(all(feature = "embedded", target_arch = "wasm32"))] +#[wasm_bindgen(js_name = begin_tx)] +pub fn begin_tx() { + if let Ok(engine) = embedded_engine() { + let _ = engine.begin_tx(); + } +} + +#[cfg(all(feature = "embedded", target_arch = "wasm32"))] +#[wasm_bindgen(js_name = commit_tx)] +pub fn commit_tx() { + if let Ok(engine) = embedded_engine() { + let _ = engine.commit_tx(); + } +} + +#[cfg(all(feature = "embedded", target_arch = "wasm32"))] +#[wasm_bindgen(js_name = rollback_tx)] +pub fn rollback_tx() { + if let Ok(engine) = embedded_engine() { + let _ = engine.rollback_tx(); + } +} + +#[cfg(all(feature = "bridge", not(feature = "embedded"), target_arch = "wasm32"))] #[wasm_bindgen(js_name = exec_sql)] pub fn exec_sql(sql: &str, params_json: &str) -> JsValue { radroots_sql_wasm_bridge::exec(sql, params_json) } -#[cfg(all(feature = "bridge", target_arch = "wasm32"))] +#[cfg(all(feature = "bridge", not(feature = "embedded"), target_arch = "wasm32"))] #[wasm_bindgen(js_name = query_sql)] pub fn query_sql(sql: &str, params_json: &str) -> JsValue { radroots_sql_wasm_bridge::query(sql, params_json) } -#[cfg(all(feature = "bridge", target_arch = "wasm32"))] +#[cfg(all(feature = "bridge", not(feature = "embedded"), target_arch = "wasm32"))] pub fn export_bytes() -> JsValue { radroots_sql_wasm_bridge::export_bytes() } -#[cfg(all(feature = "bridge", target_arch = "wasm32"))] +#[cfg(all(feature = "bridge", not(feature = "embedded"), target_arch = "wasm32"))] #[wasm_bindgen(js_name = begin_tx)] pub fn begin_tx() { radroots_sql_wasm_bridge::begin_tx() } -#[cfg(all(feature = "bridge", target_arch = "wasm32"))] +#[cfg(all(feature = "bridge", not(feature = "embedded"), target_arch = "wasm32"))] #[wasm_bindgen(js_name = commit_tx)] pub fn commit_tx() { radroots_sql_wasm_bridge::commit_tx() } -#[cfg(all(feature = "bridge", target_arch = "wasm32"))] +#[cfg(all(feature = "bridge", not(feature = "embedded"), target_arch = "wasm32"))] #[wasm_bindgen(js_name = rollback_tx)] pub fn rollback_tx() { radroots_sql_wasm_bridge::rollback_tx()