From 02db72cc2cdb607110f07da83dabe5756e7b0f6e Mon Sep 17 00:00:00 2001 From: Pekka Enberg Date: Thu, 31 Jul 2025 14:56:04 +0300 Subject: [PATCH] Implement JavaScript bindings with minimal Rust core This rewrites the JavaScript bindings completely by exposing only primitive operations from Rust NAPI-RS code. For example, there is prepare(), bind(), and step(), but high level interfaces like all() and get() are implemented in JavaScript. We're doing this so that we can implement async interfaces in the JavaScript layer instead of having to bring in Tokio. --- bindings/javascript/Cargo.toml | 2 +- bindings/javascript/bind.js | 70 +++ bindings/javascript/index.d.ts | 127 +++-- bindings/javascript/package.json | 1 + bindings/javascript/promise.js | 75 ++- bindings/javascript/src/lib.rs | 936 +++++++++++-------------------- bindings/javascript/sync.js | 66 ++- 7 files changed, 614 insertions(+), 663 deletions(-) create mode 100644 bindings/javascript/bind.js diff --git a/bindings/javascript/Cargo.toml b/bindings/javascript/Cargo.toml index b86cc0811..f39d35251 100644 --- a/bindings/javascript/Cargo.toml +++ b/bindings/javascript/Cargo.toml @@ -12,7 +12,7 @@ crate-type = ["cdylib"] [dependencies] turso_core = { workspace = true } -napi = { version = "3.1.3", default-features = false } +napi = { version = "3.1.3", default-features = false, features = ["napi6"] } napi-derive = { version = "3.1.1", default-features = true } tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } diff --git a/bindings/javascript/bind.js b/bindings/javascript/bind.js new file mode 100644 index 000000000..7e35d1d8d --- /dev/null +++ b/bindings/javascript/bind.js @@ -0,0 +1,70 @@ +// Bind parameters to a statement. +// +// This function is used to bind parameters to a statement. It supports both +// named and positional parameters, and nested arrays. +// +// The `stmt` parameter is a statement object. +// The `params` parameter is an array of parameters. +// +// The function returns void. +function bindParams(stmt, params) { + const len = params?.length; + if (len === 0) { + return; + } + if (len === 1) { + const param = params[0]; + if (isPlainObject(param)) { + bindNamedParams(stmt, param); + return; + } + bindValue(stmt, 1, param); + return; + } + bindPositionalParams(stmt, params); +} + +// Check if object is plain (no prototype chain) +function isPlainObject(obj) { + if (!obj || typeof obj !== 'object') return false; + const proto = Object.getPrototypeOf(obj); + return proto === Object.prototype || proto === null; +} + +// Handle named parameters +function bindNamedParams(stmt, paramObj) { + const paramCount = stmt.parameterCount(); + + for (let i = 1; i <= paramCount; i++) { + const paramName = stmt.parameterName(i); + if (paramName) { + const key = paramName.substring(1); // Remove ':' or '$' prefix + const value = paramObj[key]; + + if (value !== undefined) { + bindValue(stmt, i, value); + } + } + } +} + +// Handle positional parameters (including nested arrays) +function bindPositionalParams(stmt, params) { + let bindIndex = 1; + for (let i = 0; i < params.length; i++) { + const param = params[i]; + if (Array.isArray(param)) { + for (let j = 0; j < param.length; j++) { + bindValue(stmt, bindIndex++, param[j]); + } + } else { + bindValue(stmt, bindIndex++, param); + } + } +} + +function bindValue(stmt, index, value) { + stmt.bindAt(index, value); +} + +module.exports = { bindParams }; \ No newline at end of file diff --git a/bindings/javascript/index.d.ts b/bindings/javascript/index.d.ts index 359852dca..f38dfcf6d 100644 --- a/bindings/javascript/index.d.ts +++ b/bindings/javascript/index.d.ts @@ -1,46 +1,101 @@ /* auto-generated by NAPI-RS */ /* eslint-disable */ +/** A database connection. */ export declare class Database { - memory: boolean - readonly: boolean - open: boolean - name: string - constructor(path: string, options?: OpenDatabaseOptions | undefined | null) + /** + * Creates a new database instance. + * + * # Arguments + * * `path` - The path to the database file. + */ + constructor(path: string) + /** Returns whether the database is in memory-only mode. */ + get memory(): boolean + /** + * Executes a batch of SQL statements. + * + * # Arguments + * + * * `sql` - The SQL statements to execute. + * + * # Returns + */ + batch(sql: string): void + /** + * Prepares a statement for execution. + * + * # Arguments + * + * * `sql` - The SQL statement to prepare. + * + * # Returns + * + * A `Statement` instance. + */ prepare(sql: string): Statement - pragma(pragmaName: string, options?: PragmaOptions | undefined | null): unknown - backup(): void - serialize(): void - function(): void - aggregate(): void - table(): void - loadExtension(path: string): void - exec(sql: string): void + /** + * Returns the rowid of the last row inserted. + * + * # Returns + * + * The rowid of the last row inserted. + */ + lastInsertRowid(): number + /** + * Returns the number of changes made by the last statement. + * + * # Returns + * + * The number of changes made by the last statement. + */ + changes(): number + /** + * Returns the total number of changes made by all statements. + * + * # Returns + * + * The total number of changes made by all statements. + */ + totalChanges(): number + /** + * Closes the database connection. + * + * # Returns + * + * `Ok(())` if the database is closed successfully. + */ close(): void + /** Runs the I/O loop synchronously. */ + ioLoopSync(): void + /** Runs the I/O loop asynchronously, returning a Promise. */ + ioLoopAsync(): Promise } +/** A prepared statement. */ export declare class Statement { - source: string - get(args?: Array | undefined | null): unknown - run(args?: Array | undefined | null): RunResult - all(args?: Array | undefined | null): unknown - pluck(pluck?: boolean | undefined | null): void - static expand(): void + reset(): void + /** Returns the number of parameters in the statement. */ + parameterCount(): number + /** + * Returns the name of a parameter at a specific 1-based index. + * + * # Arguments + * + * * `index` - The 1-based parameter index. + */ + parameterName(index: number): string | null + /** + * Binds a parameter at a specific 1-based index with explicit type. + * + * # Arguments + * + * * `index` - The 1-based parameter index. + * * `value_type` - The type constant (0=null, 1=int, 2=float, 3=text, 4=blob). + * * `value` - The value to bind. + */ + bindAt(index: number, value: unknown): void + step(): unknown raw(raw?: boolean | undefined | null): void - static columns(): void - bind(args?: Array | undefined | null): Statement -} - -export interface OpenDatabaseOptions { - readonly?: boolean - fileMustExist?: boolean - timeout?: number -} - -export interface PragmaOptions { - simple: boolean -} - -export interface RunResult { - changes: number - lastInsertRowid: number + pluck(pluck?: boolean | undefined | null): void + finalize(): void } diff --git a/bindings/javascript/package.json b/bindings/javascript/package.json index 5c050138b..e0d5e252e 100644 --- a/bindings/javascript/package.json +++ b/bindings/javascript/package.json @@ -12,6 +12,7 @@ "./sync": "./sync.js" }, "files": [ + "bindjs", "browser.js", "index.js", "promise.js", diff --git a/bindings/javascript/promise.js b/bindings/javascript/promise.js index 64d4d10c6..6e9347a45 100644 --- a/bindings/javascript/promise.js +++ b/bindings/javascript/promise.js @@ -1,6 +1,7 @@ "use strict"; const { Database: NativeDB } = require("./index.js"); +const { bindParams } = require("./bind.js"); const SqliteError = require("./sqlite-error.js"); @@ -138,12 +139,12 @@ class Database { if (typeof options !== "object") throw new TypeError("Expected second argument to be an options object"); - const simple = options["simple"]; const pragma = `PRAGMA ${source}`; - - return simple - ? this.db.pragma(source, { simple: true }) - : this.db.pragma(source); + + const stmt = this.prepare(pragma); + const results = stmt.all(); + + return results; } backup(filename, options) { @@ -181,7 +182,7 @@ class Database { */ exec(sql) { try { - this.db.exec(sql); + this.db.batch(sql); } catch (err) { throw convertError(err); } @@ -250,8 +251,27 @@ class Statement { /** * Executes the SQL statement and returns an info object. */ - run(...bindParameters) { - return this.stmt.run(bindParameters.flat()); + async run(...bindParameters) { + const totalChangesBefore = this.db.db.totalChanges(); + + this.stmt.reset(); + bindParams(this.stmt, bindParameters); + + while (true) { + const result = this.stmt.step(); + if (result.io) { + await this.db.db.ioLoopAsync(); + continue; + } + if (result.done) { + break; + } + } + + const lastInsertRowid = this.db.db.lastInsertRowid(); + const changes = this.db.db.totalChanges() === totalChangesBefore ? 0 : this.db.db.changes(); + + return { changes, lastInsertRowid }; } /** @@ -259,8 +279,21 @@ class Statement { * * @param bindParameters - The bind parameters for executing the statement. */ - get(...bindParameters) { - return this.stmt.get(bindParameters.flat()); + async get(...bindParameters) { + this.stmt.reset(); + bindParams(this.stmt, bindParameters); + + while (true) { + const result = this.stmt.step(); + if (result.io) { + await this.db.db.ioLoopAsync(); + continue; + } + if (result.done) { + return undefined; + } + return result.value; + } } /** @@ -277,8 +310,23 @@ class Statement { * * @param bindParameters - The bind parameters for executing the statement. */ - all(...bindParameters) { - return this.stmt.all(bindParameters.flat()); + async all(...bindParameters) { + this.stmt.reset(); + bindParams(this.stmt, bindParameters); + const rows = []; + + while (true) { + const result = this.stmt.step(); + if (result.io) { + await this.db.db.ioLoopAsync(); + continue; + } + if (result.done) { + break; + } + rows.push(result.value); + } + return rows; } /** @@ -304,7 +352,8 @@ class Statement { */ bind(...bindParameters) { try { - return new Statement(this.stmt.bind(bindParameters.flat()), this.db); + bindParams(this.stmt, bindParameters); + return this; } catch (err) { throw convertError(err); } diff --git a/bindings/javascript/src/lib.rs b/bindings/javascript/src/lib.rs index aa0c4772b..611494b4a 100644 --- a/bindings/javascript/src/lib.rs +++ b/bindings/javascript/src/lib.rs @@ -1,644 +1,431 @@ -#![deny(clippy::all)] +//! JavaScript bindings for the Turso library. +//! +//! These bindings provide a thin layer that exposes Turso's Rust API to JavaScript, +//! maintaining close alignment with the underlying implementation while offering +//! the following core database operations: +//! +//! - Opening and closing database connections +//! - Preparing SQL statements +//! - Binding parameters to prepared statements +//! - Iterating through query results +//! - Managing the I/O event loop -use std::cell::{RefCell, RefMut}; -use std::num::{NonZero, NonZeroUsize}; - -use std::rc::Rc; -use std::sync::{Arc, OnceLock}; - -use napi::bindgen_prelude::{JsObjectValue, Null, Object, ToNapiValue}; -use napi::{bindgen_prelude::ObjectFinalize, Env, JsValue, Unknown}; +use napi::bindgen_prelude::*; +use napi::{Env, Task}; use napi_derive::napi; -use tracing_subscriber::fmt::format::FmtSpan; -use tracing_subscriber::EnvFilter; -use turso_core::{LimboError, StepResult}; +use std::{cell::RefCell, num::NonZeroUsize, sync::Arc}; -static TRACING_INIT: OnceLock<()> = OnceLock::new(); - -fn init_tracing() { - TRACING_INIT.get_or_init(|| { - tracing_subscriber::fmt() - .with_thread_ids(true) - .with_span_events(FmtSpan::ACTIVE) - .with_env_filter(EnvFilter::from_default_env()) - .init(); - }); +/// The presentation mode for rows. +#[derive(Debug, Clone)] +enum PresentationMode { + Expanded, + Raw, + Pluck, } -#[derive(Default)] -#[napi(object)] -pub struct OpenDatabaseOptions { - pub readonly: Option, - pub file_must_exist: Option, - pub timeout: Option, - // verbose => Callback, -} - -impl OpenDatabaseOptions { - fn readonly(&self) -> bool { - self.readonly.unwrap_or(false) - } -} - -#[napi(object)] -pub struct PragmaOptions { - pub simple: bool, -} - -#[napi(object)] -pub struct RunResult { - pub changes: i64, - pub last_insert_rowid: i64, -} - -#[napi(custom_finalize)] -#[derive(Clone)] +/// A database connection. +#[napi] pub struct Database { - #[napi(writable = false)] - pub memory: bool, - - #[napi(writable = false)] - pub readonly: bool, - // #[napi(writable = false)] - // pub in_transaction: bool, - #[napi(writable = false)] - pub open: bool, - #[napi(writable = false)] - pub name: String, - db: Option>, + _db: Arc, + io: Arc, conn: Arc, - _io: Arc, -} - -impl ObjectFinalize for Database { - // TODO: check if something more is required - fn finalize(self, _env: Env) -> napi::Result<()> { - self.conn.close().map_err(into_napi_error)?; - Ok(()) - } + is_memory: bool, } #[napi] impl Database { + /// Creates a new database instance. + /// + /// # Arguments + /// * `path` - The path to the database file. #[napi(constructor)] - pub fn new(path: String, options: Option) -> napi::Result { - init_tracing(); - - let memory = path == ":memory:"; - let io: Arc = if memory { + pub fn new(path: String) -> Result { + let is_memory = path == ":memory:"; + let io: Arc = if is_memory { Arc::new(turso_core::MemoryIO::new()) } else { - Arc::new(turso_core::PlatformIO::new().map_err(into_napi_sqlite_error)?) - }; - let opts = options.unwrap_or_default(); - let flag = if opts.readonly() { - turso_core::OpenFlags::ReadOnly - } else { - turso_core::OpenFlags::Create + Arc::new(turso_core::PlatformIO::new().map_err(|e| { + Error::new(Status::GenericFailure, format!("Failed to create IO: {e}")) + })?) }; + let file = io - .open_file(&path, flag, false) - .map_err(|err| into_napi_error_with_message("SQLITE_CANTOPEN".to_owned(), err))?; + .open_file(&path, turso_core::OpenFlags::Create, false) + .map_err(|e| Error::new(Status::GenericFailure, format!("Failed to open file: {e}")))?; let db_file = Arc::new(DatabaseFile::new(file)); - let db = turso_core::Database::open(io.clone(), &path, db_file, false, false) - .map_err(into_napi_sqlite_error)?; - let conn = db.connect().map_err(into_napi_sqlite_error)?; + let db = + turso_core::Database::open(io.clone(), &path, db_file, false, false).map_err(|e| { + Error::new( + Status::GenericFailure, + format!("Failed to open database: {e}"), + ) + })?; - Ok(Self { - readonly: opts.readonly(), - memory, - db: Some(db), + let conn = db + .connect() + .map_err(|e| Error::new(Status::GenericFailure, format!("Failed to connect: {e}")))?; + + Ok(Database { + _db: db, + io, conn, - open: true, - name: path, - _io: io, + is_memory, }) } - #[napi] - pub fn prepare(&self, sql: String) -> napi::Result { - let stmt = self.conn.prepare(&sql).map_err(into_napi_error)?; - Ok(Statement::new(RefCell::new(stmt), self.clone(), sql)) + /// Returns whether the database is in memory-only mode. + #[napi(getter)] + pub fn memory(&self) -> bool { + self.is_memory } + /// Executes a batch of SQL statements. + /// + /// # Arguments + /// + /// * `sql` - The SQL statements to execute. + /// + /// # Returns #[napi] - pub fn pragma<'env>( - &self, - env: &'env Env, - pragma_name: String, - options: Option, - ) -> napi::Result> { - let sql = format!("PRAGMA {pragma_name}"); - let stmt = self.prepare(sql)?; - match options { - Some(PragmaOptions { simple: true, .. }) => { - let mut stmt = stmt.inner.borrow_mut(); - loop { - match stmt.step().map_err(into_napi_error)? { - turso_core::StepResult::Row => { - let row: Vec<_> = stmt.row().unwrap().get_values().cloned().collect(); - return to_js_value(env, row[0].clone()); - } - turso_core::StepResult::Done => { - return ToNapiValue::into_unknown((), env); - } - turso_core::StepResult::IO => { - stmt.run_once().map_err(into_napi_error)?; - continue; - } - step @ turso_core::StepResult::Interrupt - | step @ turso_core::StepResult::Busy => { - return Err(napi::Error::new( - napi::Status::GenericFailure, - format!("{step:?}"), - )) - } - } - } - } - _ => Ok(stmt.run_internal(env, None)?), - } - } - - #[napi] - pub fn backup(&self) { - todo!() - } - - #[napi] - pub fn serialize(&self) { - todo!() - } - - #[napi] - pub fn function(&self) { - todo!() - } - - #[napi] - pub fn aggregate(&self) { - todo!() - } - - #[napi] - pub fn table(&self) { - todo!() - } - - #[napi] - pub fn load_extension(&self, path: String) -> napi::Result<()> { - let ext_path = turso_core::resolve_ext_path(path.as_str()).map_err(into_napi_error)?; - #[cfg(not(target_family = "wasm"))] - { - self.conn - .load_extension(ext_path) - .map_err(into_napi_error)?; - } + pub fn batch(&self, sql: String) -> Result<()> { + self.conn.prepare_execute_batch(&sql).map_err(|e| { + Error::new( + Status::GenericFailure, + format!("Failed to execute batch: {e}"), + ) + })?; Ok(()) } + /// Prepares a statement for execution. + /// + /// # Arguments + /// + /// * `sql` - The SQL statement to prepare. + /// + /// # Returns + /// + /// A `Statement` instance. #[napi] - pub fn exec(&self, sql: String) -> napi::Result<(), String> { - let query_runner = self.conn.query_runner(sql.as_bytes()); + pub fn prepare(&self, sql: String) -> Result { + let stmt = self.conn.prepare(&sql).map_err(|e| { + Error::new( + Status::GenericFailure, + format!("Failed to prepare statement: {e}"), + ) + })?; + let column_names: Vec = (0..stmt.num_columns()) + .map(|i| std::ffi::CString::new(stmt.get_column_name(i).to_string()).unwrap()) + .collect(); + Ok(Statement { + stmt: RefCell::new(Some(stmt)), + column_names, + mode: RefCell::new(PresentationMode::Expanded), + }) + } - // Since exec doesn't return any values, we can just iterate over the results - for output in query_runner { - match output { - Ok(Some(mut stmt)) => loop { - match stmt.step() { - Ok(StepResult::Row) => continue, - Ok(StepResult::IO) => stmt.run_once().map_err(into_napi_sqlite_error)?, - Ok(StepResult::Done) => break, - Ok(StepResult::Interrupt | StepResult::Busy) => { - return Err(napi::Error::new( - "SQLITE_ERROR".to_owned(), - "Statement execution interrupted or busy".to_string(), - )); - } - Err(err) => { - return Err(napi::Error::new( - "SQLITE_ERROR".to_owned(), - format!("Error executing SQL: {err}"), - )); - } - } - }, - Ok(None) => continue, - Err(err) => { - return Err(napi::Error::new( - "SQLITE_ERROR".to_owned(), - format!("Error executing SQL: {err}"), - )); - } - } - } + /// Returns the rowid of the last row inserted. + /// + /// # Returns + /// + /// The rowid of the last row inserted. + #[napi] + pub fn last_insert_rowid(&self) -> Result { + Ok(self.conn.last_insert_rowid()) + } + + /// Returns the number of changes made by the last statement. + /// + /// # Returns + /// + /// The number of changes made by the last statement. + #[napi] + pub fn changes(&self) -> Result { + Ok(self.conn.changes()) + } + + /// Returns the total number of changes made by all statements. + /// + /// # Returns + /// + /// The total number of changes made by all statements. + #[napi] + pub fn total_changes(&self) -> Result { + Ok(self.conn.total_changes()) + } + + /// Closes the database connection. + /// + /// # Returns + /// + /// `Ok(())` if the database is closed successfully. + #[napi] + pub fn close(&self) -> Result<()> { + // Database close is handled automatically when dropped Ok(()) } + /// Runs the I/O loop synchronously. #[napi] - pub fn close(&mut self) -> napi::Result<()> { - if self.open { - self.conn.close().map_err(into_napi_error)?; - self.db.take(); - self.open = false; - } + pub fn io_loop_sync(&self) -> Result<()> { + self.io + .run_once() + .map_err(|e| Error::new(Status::GenericFailure, format!("IO error: {e}")))?; Ok(()) } + + /// Runs the I/O loop asynchronously, returning a Promise. + #[napi(ts_return_type = "Promise")] + pub fn io_loop_async(&self) -> AsyncTask { + let io = self.io.clone(); + AsyncTask::new(IoLoopTask { io }) + } } -#[derive(Debug, Clone)] -enum PresentationMode { - Raw, - Pluck, - None, -} - +/// A prepared statement. #[napi] -#[derive(Clone)] pub struct Statement { - // TODO: implement each property when core supports it - // #[napi(able = false)] - // pub reader: bool, - // #[napi(writable = false)] - // pub readonly: bool, - // #[napi(writable = false)] - // pub busy: bool, - #[napi(writable = false)] - pub source: String, - - database: Database, - presentation_mode: PresentationMode, - binded: bool, - inner: Rc>, + stmt: RefCell>, + column_names: Vec, + mode: RefCell, } #[napi] impl Statement { - pub fn new(inner: RefCell, database: Database, source: String) -> Self { - Self { - inner: Rc::new(inner), - database, - source, - presentation_mode: PresentationMode::None, - binded: false, - } + #[napi] + pub fn reset(&self) -> Result<()> { + let mut stmt = self.stmt.borrow_mut(); + let stmt = stmt + .as_mut() + .ok_or_else(|| Error::new(Status::GenericFailure, "Statement has been finalized"))?; + stmt.reset(); + Ok(()) } + /// Returns the number of parameters in the statement. #[napi] - pub fn get<'env>( - &self, - env: &'env Env, - args: Option>, - ) -> napi::Result> { - let mut stmt = self.check_and_bind(env, args)?; + pub fn parameter_count(&self) -> Result { + let stmt = self.stmt.borrow(); + let stmt = stmt + .as_ref() + .ok_or_else(|| Error::new(Status::GenericFailure, "Statement has been finalized"))?; + Ok(stmt.parameters_count() as u32) + } - loop { - let step = stmt.step().map_err(into_napi_error)?; - match step { - turso_core::StepResult::Row => { - let row = stmt.row().unwrap(); + /// Returns the name of a parameter at a specific 1-based index. + /// + /// # Arguments + /// + /// * `index` - The 1-based parameter index. + #[napi] + pub fn parameter_name(&self, index: u32) -> Result> { + let stmt = self.stmt.borrow(); + let stmt = stmt + .as_ref() + .ok_or_else(|| Error::new(Status::GenericFailure, "Statement has been finalized"))?; - match self.presentation_mode { - PresentationMode::Raw => { - let mut raw_obj = env.create_array(row.len() as u32)?; - for (idx, value) in row.get_values().enumerate() { - let js_value = to_js_value(env, value.clone()); + let non_zero_idx = NonZeroUsize::new(index as usize).ok_or_else(|| { + Error::new(Status::InvalidArg, "Parameter index must be greater than 0") + })?; - raw_obj.set(idx as u32, js_value)?; - } - return Ok(raw_obj.coerce_to_object()?.to_unknown()); - } - PresentationMode::Pluck => { - let (_, value) = - row.get_values().enumerate().next().ok_or(napi::Error::new( - napi::Status::GenericFailure, - "Pluck mode requires at least one column in the result", - ))?; + Ok(stmt.parameters().name(non_zero_idx).map(|s| s.to_string())) + } - let result = to_js_value(env, value.clone())?; - return ToNapiValue::into_unknown(result, env); - } - PresentationMode::None => { - let mut obj = Object::new(env)?; + /// Binds a parameter at a specific 1-based index with explicit type. + /// + /// # Arguments + /// + /// * `index` - The 1-based parameter index. + /// * `value_type` - The type constant (0=null, 1=int, 2=float, 3=text, 4=blob). + /// * `value` - The value to bind. + #[napi] + pub fn bind_at(&self, index: u32, value: Unknown) -> Result<()> { + let mut stmt = self.stmt.borrow_mut(); + let stmt = stmt + .as_mut() + .ok_or_else(|| Error::new(Status::GenericFailure, "Statement has been finalized"))?; - for (idx, value) in row.get_values().enumerate() { - let key = stmt.get_column_name(idx); - let js_value = to_js_value(env, value.clone()); + let non_zero_idx = NonZeroUsize::new(index as usize).ok_or_else(|| { + Error::new(Status::InvalidArg, "Parameter index must be greater than 0") + })?; - obj.set_named_property(&key, js_value)?; - } - - return Ok(obj.to_unknown()); - } - } - } - turso_core::StepResult::Done => return ToNapiValue::into_unknown((), env), - turso_core::StepResult::IO => { - stmt.run_once().map_err(into_napi_error)?; - continue; - } - turso_core::StepResult::Interrupt | turso_core::StepResult::Busy => { - return Err(napi::Error::new( - napi::Status::GenericFailure, - format!("{step:?}"), - )) + let value_type = value.get_type()?; + let turso_value = match value_type { + ValueType::Null => turso_core::Value::Null, + ValueType::Number => { + let n: f64 = unsafe { value.cast()? }; + if n.fract() == 0.0 { + turso_core::Value::Integer(n as i64) + } else { + turso_core::Value::Float(n) } } - } - } - - #[napi] - pub fn run(&self, env: Env, args: Option>) -> napi::Result { - self.run_and_build_info_object(|| self.run_internal(&env, args)) - } - - fn run_internal<'env>( - &self, - env: &'env Env, - args: Option>, - ) -> napi::Result> { - let stmt = self.check_and_bind(env, args)?; - - self.internal_all(env, stmt) - } - - fn run_and_build_info_object( - &self, - query_fn: impl FnOnce() -> Result, - ) -> Result { - let total_changes_before = self.database.conn.total_changes(); - - query_fn()?; - - let last_insert_rowid = self.database.conn.last_insert_rowid(); - let changes = if self.database.conn.total_changes() == total_changes_before { - 0 - } else { - self.database.conn.changes() + ValueType::String => { + let s = value.coerce_to_string()?.into_utf8()?; + turso_core::Value::Text(s.as_str()?.to_owned().into()) + } + ValueType::Boolean => { + let b: bool = unsafe { value.cast()? }; + turso_core::Value::Integer(if b { 1 } else { 0 }) + } + ValueType::Object => { + // Try to cast as Buffer first, fallback to string conversion + if let Ok(buffer) = unsafe { value.cast::() } { + turso_core::Value::Blob(buffer.to_vec()) + } else { + let s = value.coerce_to_string()?.into_utf8()?; + turso_core::Value::Text(s.as_str()?.to_owned().into()) + } + } + _ => { + // Fallback to string conversion for unknown types + let s = value.coerce_to_string()?.into_utf8()?; + turso_core::Value::Text(s.as_str()?.to_owned().into()) + } }; - Ok(RunResult { - changes, - last_insert_rowid, - }) + stmt.bind_at(non_zero_idx, turso_value); + Ok(()) } #[napi] - pub fn all<'env>( - &self, - env: &'env Env, - args: Option>, - ) -> napi::Result> { - let stmt = self.check_and_bind(env, args)?; + pub fn step<'env>(&self, env: &'env Env) -> Result> { + let mut stmt_ref = self.stmt.borrow_mut(); + let stmt = stmt_ref + .as_mut() + .ok_or_else(|| Error::new(Status::GenericFailure, "Statement has been finalized"))?; - self.internal_all(env, stmt) - } + let mut result = Object::new(env)?; - fn internal_all<'env>( - &self, - env: &'env Env, - mut stmt: RefMut<'_, turso_core::Statement>, - ) -> napi::Result> { - let mut results = env.create_array(1)?; - let mut index = 0; - loop { - match stmt.step().map_err(into_napi_error)? { - turso_core::StepResult::Row => { - let row = stmt.row().unwrap(); + match stmt.step() { + Ok(turso_core::StepResult::Row) => { + result.set_named_property("done", false)?; - match self.presentation_mode { + let row_data = stmt + .row() + .ok_or_else(|| Error::new(Status::GenericFailure, "No row data available"))?; + + let mode = self.mode.borrow(); + let row_value = + match *mode { PresentationMode::Raw => { - let mut raw_array = env.create_array(row.len() as u32)?; - for (idx, value) in row.get_values().enumerate() { - let js_value = to_js_value(env, value.clone())?; + let mut raw_array = env.create_array(row_data.len() as u32)?; + for (idx, value) in row_data.get_values().enumerate() { + let js_value = to_js_value(env, value)?; raw_array.set(idx as u32, js_value)?; } - results.set_element(index, raw_array.coerce_to_object()?)?; - index += 1; - continue; + raw_array.coerce_to_object()?.to_unknown() } PresentationMode::Pluck => { - let (_, value) = - row.get_values().enumerate().next().ok_or(napi::Error::new( + let (_, value) = row_data.get_values().enumerate().next().ok_or( + napi::Error::new( napi::Status::GenericFailure, "Pluck mode requires at least one column in the result", - ))?; - let js_value = to_js_value(env, value.clone())?; - results.set_element(index, js_value)?; - index += 1; - continue; + ), + )?; + to_js_value(env, value)? } - PresentationMode::None => { - let mut obj = Object::new(env)?; - for (idx, value) in row.get_values().enumerate() { - let key = stmt.get_column_name(idx); - let js_value = to_js_value(env, value.clone()); - obj.set_named_property(&key, js_value)?; + PresentationMode::Expanded => { + let row = Object::new(env)?; + let raw_row = row.raw(); + let raw_env = env.raw(); + for idx in 0..row_data.len() { + let value = row_data.get_value(idx); + let column_name = &self.column_names[idx]; + let js_value = to_js_value(env, value)?; + unsafe { + napi::sys::napi_set_named_property( + raw_env, + raw_row, + column_name.as_ptr(), + js_value.raw(), + ); + } } - results.set_element(index, obj)?; - index += 1; + row.to_unknown() } - } - } - turso_core::StepResult::Done => { - break; - } - turso_core::StepResult::IO => { - stmt.run_once().map_err(into_napi_error)?; - } - turso_core::StepResult::Interrupt | turso_core::StepResult::Busy => { - return Err(napi::Error::new( - napi::Status::GenericFailure, - format!("{:?}", stmt.step()), - )); - } + }; + + result.set_named_property("value", row_value)?; + } + Ok(turso_core::StepResult::Done) => { + result.set_named_property("done", true)?; + result.set_named_property("value", Null)?; + } + Ok(turso_core::StepResult::IO) => { + result.set_named_property("io", true)?; + result.set_named_property("value", Null)?; + } + Ok(turso_core::StepResult::Interrupt) => { + return Err(Error::new( + Status::GenericFailure, + "Statement was interrupted", + )); + } + Ok(turso_core::StepResult::Busy) => { + return Err(Error::new(Status::GenericFailure, "Database is busy")); + } + Err(e) => { + return Err(Error::new( + Status::GenericFailure, + format!("Step failed: {e}"), + )) } } - Ok(results.to_unknown()) - } - - #[napi] - pub fn pluck(&mut self, pluck: Option) { - self.presentation_mode = match pluck { - Some(false) => PresentationMode::None, - _ => PresentationMode::Pluck, - }; - } - - #[napi] - pub fn expand() { - todo!() + Ok(result.to_unknown()) } + /// Sets the presentation mode to raw. #[napi] pub fn raw(&mut self, raw: Option) { - self.presentation_mode = match raw { - Some(false) => PresentationMode::None, + self.mode = RefCell::new(match raw { + Some(false) => PresentationMode::Expanded, _ => PresentationMode::Raw, - }; + }); } + /// Sets the presentation mode to pluck. #[napi] - pub fn columns() { - todo!() + pub fn pluck(&mut self, pluck: Option) { + self.mode = RefCell::new(match pluck { + Some(false) => PresentationMode::Expanded, + _ => PresentationMode::Pluck, + }); } + /// Finalizes the statement. #[napi] - pub fn bind(&mut self, env: Env, args: Option>) -> napi::Result { - self.check_and_bind(&env, args) - .map_err(with_sqlite_error_message)?; - self.binded = true; - - Ok(self.clone()) - } - - /// Check if the Statement is already binded by the `bind()` method - /// and bind values to variables. - fn check_and_bind( - &self, - env: &Env, - args: Option>, - ) -> napi::Result> { - let mut stmt = self.inner.borrow_mut(); - stmt.reset(); - if let Some(args) = args { - if self.binded { - let err = napi::Error::new( - into_convertible_type_error_message("TypeError"), - "The bind() method can only be invoked once per statement object", - ); - unsafe { - napi::JsTypeError::from(err).throw_into(env.raw()); - } - - return Err(napi::Error::from_status(napi::Status::PendingException)); - } - - if args.len() == 1 { - if matches!(args[0].get_type()?, napi::ValueType::Object) { - let obj: Object = args.into_iter().next().unwrap().coerce_to_object()?; - - if obj.is_array()? { - bind_positional_param_array(&mut stmt, &obj)?; - } else { - bind_host_params(&mut stmt, &obj)?; - } - } else { - bind_single_param(&mut stmt, args.into_iter().next().unwrap())?; - } - } else { - bind_positional_params(&mut stmt, args)?; - } - } - - Ok(stmt) + pub fn finalize(&self) -> Result<()> { + self.stmt.borrow_mut().take(); + Ok(()) } } -fn bind_positional_params( - stmt: &mut RefMut<'_, turso_core::Statement>, - args: Vec, -) -> Result<(), napi::Error> { - for (i, elem) in args.into_iter().enumerate() { - let value = from_js_value(elem)?; - stmt.bind_at(NonZeroUsize::new(i + 1).unwrap(), value); - } - Ok(()) +/// Async task for running the I/O loop. +pub struct IoLoopTask { + io: Arc, } -fn bind_host_params( - stmt: &mut RefMut<'_, turso_core::Statement>, - obj: &Object, -) -> Result<(), napi::Error> { - if first_key_is_number(obj) { - bind_numbered_params(stmt, obj)?; - } else { - bind_named_params(stmt, obj)?; +impl Task for IoLoopTask { + type Output = (); + type JsValue = (); + + fn compute(&mut self) -> napi::Result { + self.io.run_once().map_err(|e| { + napi::Error::new(napi::Status::GenericFailure, format!("IO error: {e}")) + })?; + Ok(()) } - Ok(()) -} - -fn first_key_is_number(obj: &Object) -> bool { - Object::keys(obj) - .iter() - .flatten() - .filter(|key| matches!(obj.has_own_property(key), Ok(result) if result)) - .take(1) - .any(|key| str::parse::(key).is_ok()) -} - -fn bind_numbered_params( - stmt: &mut RefMut<'_, turso_core::Statement>, - obj: &Object, -) -> Result<(), napi::Error> { - for key in Object::keys(obj)?.iter() { - let Ok(param_idx) = str::parse::(key) else { - return Err(napi::Error::new( - napi::Status::GenericFailure, - "cannot mix numbers and strings", - )); - }; - let Some(non_zero) = NonZero::new(param_idx as usize) else { - return Err(napi::Error::new( - napi::Status::GenericFailure, - "numbered parameters cannot be lower than 1", - )); - }; - - stmt.bind_at(non_zero, from_js_value(obj.get_named_property(key)?)?); + fn resolve(&mut self, _env: Env, _output: Self::Output) -> napi::Result { + Ok(()) } - Ok(()) } -fn bind_named_params( - stmt: &mut RefMut<'_, turso_core::Statement>, - obj: &Object, -) -> Result<(), napi::Error> { - for idx in 1..stmt.parameters_count() + 1 { - let non_zero_idx = NonZero::new(idx).unwrap(); - - let param = stmt.parameters().name(non_zero_idx); - let Some(name) = param else { - return Err(napi::Error::from_reason(format!( - "could not find named parameter with index {idx}" - ))); - }; - - let value = obj.get_named_property::(&name[1..])?; - stmt.bind_at(non_zero_idx, from_js_value(value)?); - } - - Ok(()) -} - -fn bind_positional_param_array( - stmt: &mut RefMut<'_, turso_core::Statement>, - obj: &Object, -) -> Result<(), napi::Error> { - assert!(obj.is_array()?, "bind_array can only be called with arrays"); - - for idx in 1..obj.get_array_length()? { - stmt.bind_at( - NonZero::new(idx as usize).unwrap(), - from_js_value(obj.get_element(idx)?)?, - ); - } - - Ok(()) -} - -fn bind_single_param( - stmt: &mut RefMut<'_, turso_core::Statement>, - obj: napi::Unknown, -) -> Result<(), napi::Error> { - stmt.bind_at(NonZero::new(1).unwrap(), from_js_value(obj)?); - Ok(()) -} - -fn to_js_value<'a>(env: &'a napi::Env, value: turso_core::Value) -> napi::Result> { +/// Convert a Turso value to a JavaScript value. +fn to_js_value<'a>(env: &'a napi::Env, value: &turso_core::Value) -> napi::Result> { match value { turso_core::Value::Null => ToNapiValue::into_unknown(Null, env), turso_core::Value::Integer(i) => ToNapiValue::into_unknown(i, env), @@ -648,37 +435,6 @@ fn to_js_value<'a>(env: &'a napi::Env, value: turso_core::Value) -> napi::Result } } -fn from_js_value(value: Unknown<'_>) -> napi::Result { - match value.get_type()? { - napi::ValueType::Undefined | napi::ValueType::Null | napi::ValueType::Unknown => { - Ok(turso_core::Value::Null) - } - napi::ValueType::Boolean => { - let b = value.coerce_to_bool()?; - Ok(turso_core::Value::Integer(b as i64)) - } - napi::ValueType::Number => { - let num = value.coerce_to_number()?.get_double()?; - if num.fract() == 0.0 { - Ok(turso_core::Value::Integer(num as i64)) - } else { - Ok(turso_core::Value::Float(num)) - } - } - napi::ValueType::String => { - let s = value.coerce_to_string()?; - Ok(turso_core::Value::Text(s.into_utf8()?.as_str()?.into())) - } - napi::ValueType::Symbol - | napi::ValueType::Object - | napi::ValueType::Function - | napi::ValueType::External => Err(napi::Error::new( - napi::Status::GenericFailure, - "Unsupported type", - )), - } -} - struct DatabaseFile { file: Arc, } @@ -711,13 +467,14 @@ impl turso_core::DatabaseStorage for DatabaseFile { fn write_page( &self, page_idx: usize, - buffer: Arc>, + buffer: Arc>, c: turso_core::Completion, ) -> turso_core::Result { let size = buffer.borrow().len(); let pos = (page_idx - 1) * size; self.file.pwrite(pos, buffer, c) } + fn write_pages( &self, page_idx: usize, @@ -737,6 +494,7 @@ impl turso_core::DatabaseStorage for DatabaseFile { fn size(&self) -> turso_core::Result { self.file.size() } + fn truncate( &self, len: usize, @@ -746,31 +504,3 @@ impl turso_core::DatabaseStorage for DatabaseFile { Ok(c) } } - -#[inline] -fn into_napi_error(limbo_error: LimboError) -> napi::Error { - napi::Error::new(napi::Status::GenericFailure, format!("{limbo_error}")) -} - -#[inline] -fn into_napi_sqlite_error(limbo_error: LimboError) -> napi::Error { - napi::Error::new(String::from("SQLITE_ERROR"), format!("{limbo_error}")) -} - -#[inline] -fn into_napi_error_with_message( - error_code: String, - limbo_error: LimboError, -) -> napi::Error { - napi::Error::new(error_code, format!("{limbo_error}")) -} - -#[inline] -fn with_sqlite_error_message(err: napi::Error) -> napi::Error { - napi::Error::new("SQLITE_ERROR".to_owned(), err.reason.clone()) -} - -#[inline] -fn into_convertible_type_error_message(error_type: &str) -> String { - "[TURSO_CONVERT_TYPE] ".to_owned() + error_type -} diff --git a/bindings/javascript/sync.js b/bindings/javascript/sync.js index 64d4d10c6..1cf5954ac 100644 --- a/bindings/javascript/sync.js +++ b/bindings/javascript/sync.js @@ -1,6 +1,7 @@ "use strict"; const { Database: NativeDB } = require("./index.js"); +const { bindParams } = require("./bind.js"); const SqliteError = require("./sqlite-error.js"); @@ -138,12 +139,12 @@ class Database { if (typeof options !== "object") throw new TypeError("Expected second argument to be an options object"); - const simple = options["simple"]; const pragma = `PRAGMA ${source}`; - - return simple - ? this.db.pragma(source, { simple: true }) - : this.db.pragma(source); + + const stmt = this.prepare(pragma); + const results = stmt.all(); + + return results; } backup(filename, options) { @@ -181,7 +182,7 @@ class Database { */ exec(sql) { try { - this.db.exec(sql); + this.db.batch(sql); } catch (err) { throw convertError(err); } @@ -251,7 +252,25 @@ class Statement { * Executes the SQL statement and returns an info object. */ run(...bindParameters) { - return this.stmt.run(bindParameters.flat()); + const totalChangesBefore = this.db.db.totalChanges(); + + this.stmt.reset(); + bindParams(this.stmt, bindParameters); + for (;;) { + const result = this.stmt.step(); + if (result.io) { + this.db.db.ioLoopSync(); + continue; + } + if (result.done) { + break; + } + } + + const lastInsertRowid = this.db.db.lastInsertRowid(); + const changes = this.db.db.totalChanges() === totalChangesBefore ? 0 : this.db.db.changes(); + + return { changes, lastInsertRowid }; } /** @@ -260,7 +279,19 @@ class Statement { * @param bindParameters - The bind parameters for executing the statement. */ get(...bindParameters) { - return this.stmt.get(bindParameters.flat()); + this.stmt.reset(); + bindParams(this.stmt, bindParameters); + for (;;) { + const result = this.stmt.step(); + if (result.io) { + this.db.db.ioLoopSync(); + continue; + } + if (result.done) { + return undefined; + } + return result.value; + } } /** @@ -278,7 +309,21 @@ class Statement { * @param bindParameters - The bind parameters for executing the statement. */ all(...bindParameters) { - return this.stmt.all(bindParameters.flat()); + this.stmt.reset(); + bindParams(this.stmt, bindParameters); + const rows = []; + for (;;) { + const result = this.stmt.step(); + if (result.io) { + this.db.db.ioLoopSync(); + continue; + } + if (result.done) { + break; + } + rows.push(result.value); + } + return rows; } /** @@ -304,7 +349,8 @@ class Statement { */ bind(...bindParameters) { try { - return new Statement(this.stmt.bind(bindParameters.flat()), this.db); + bindParams(this.stmt, bindParameters); + return this; } catch (err) { throw convertError(err); }