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); }