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:
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()