diff --git a/Cargo.lock b/Cargo.lock index 6284a9e8c..ac3b51486 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1062,11 +1062,9 @@ checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674" name = "java-limbo" version = "0.0.12" dependencies = [ - "anyhow", "jni", - "lazy_static", "limbo_core", - "rand", + "thiserror 2.0.11", ] [[package]] diff --git a/NOTICE.md b/NOTICE.md index c735df441..5c654dfbf 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -18,6 +18,11 @@ This product depends on AssertJ, distributed by the AssertJ authors: * License: licenses/bindings/java/errorprone-license.md (Apache License v2.0) * Homepage: https://joel-costigliola.github.io/assertj/ +This product depends on logback, distributed by the logback authors: + +* License: licenses/bindings/java/logback-license.md (Apache License v2.0) +* Homepage: https://github.com/qos-ch/logback?tab=License-1-ov-file + This product depends on serde, distributed by the serde-rs project: * License: licenses/core/serde-apache-license.md (Apache License v2.0) diff --git a/bindings/java/Cargo.toml b/bindings/java/Cargo.toml index 79de553ac..e3b7660c5 100644 --- a/bindings/java/Cargo.toml +++ b/bindings/java/Cargo.toml @@ -12,8 +12,6 @@ crate-type = ["cdylib"] path = "rs_src/lib.rs" [dependencies] -anyhow = "1.0" limbo_core = { path = "../../core" } jni = "0.21.1" -rand = { version = "0.8.5", features = [] } -lazy_static = "1.5.0" +thiserror = "2.0.9" diff --git a/bindings/java/build.gradle.kts b/bindings/java/build.gradle.kts index fcdebad3a..c20fa561b 100644 --- a/bindings/java/build.gradle.kts +++ b/bindings/java/build.gradle.kts @@ -1,5 +1,7 @@ import net.ltgt.gradle.errorprone.CheckSeverity import net.ltgt.gradle.errorprone.errorprone +import org.gradle.api.tasks.testing.logging.TestExceptionFormat +import org.gradle.api.tasks.testing.logging.TestLogEvent plugins { java @@ -20,6 +22,9 @@ repositories { } dependencies { + implementation("ch.qos.logback:logback-classic:1.2.13") + implementation("ch.qos.logback:logback-core:1.2.13") + errorprone("com.uber.nullaway:nullaway:0.10.26") // maximum version which supports java 8 errorprone("com.google.errorprone:error_prone_core:2.10.0") // maximum version which supports java 8 @@ -46,6 +51,46 @@ tasks.test { "java.library.path", "${System.getProperty("java.library.path")}:$projectDir/src/test/resources/limbo/debug" ) + + // For our fancy test logging + testLogging { + // set options for log level LIFECYCLE + events( + TestLogEvent.FAILED, + TestLogEvent.PASSED, + TestLogEvent.SKIPPED, + TestLogEvent.STANDARD_OUT + ) + exceptionFormat = TestExceptionFormat.FULL + showExceptions = true + showCauses = true + showStackTraces = true + + // set options for log level DEBUG and INFO + debug { + events( + TestLogEvent.STARTED, + TestLogEvent.FAILED, + TestLogEvent.PASSED, + TestLogEvent.SKIPPED, + TestLogEvent.STANDARD_ERROR, + TestLogEvent.STANDARD_OUT + ) + exceptionFormat = TestExceptionFormat.FULL + } + info.events = debug.events + info.exceptionFormat = debug.exceptionFormat + + afterSuite(KotlinClosure2({ desc, result -> + if (desc.parent == null) { // will match the outermost suite + val output = "Results: ${result.resultType} (${result.testCount} tests, ${result.successfulTestCount} passed, ${result.failedTestCount} failed, ${result.skippedTestCount} skipped)" + val startItem = "| " + val endItem = " |" + val repeatLength = startItem.length + output.length + endItem.length + println("\n" + "-".repeat(repeatLength) + "\n" + startItem + output + endItem + "\n" + "-".repeat(repeatLength)) + } + })) + } } tasks.withType { diff --git a/bindings/java/rs_src/connection.rs b/bindings/java/rs_src/connection.rs deleted file mode 100644 index ef3a565bd..000000000 --- a/bindings/java/rs_src/connection.rs +++ /dev/null @@ -1,84 +0,0 @@ -use crate::cursor::Cursor; -use jni::objects::JClass; -use jni::sys::jlong; -use jni::JNIEnv; -use std::rc::Rc; -use std::sync::{Arc, Mutex}; - -#[derive(Clone)] -pub struct Connection { - pub(crate) conn: Arc>>, - pub(crate) io: Arc, -} - -/// Returns a pointer to a `Cursor` object. -/// -/// The Java application will pass this pointer to native functions, -/// which will use it to reference the `Cursor` object. -/// -/// # Arguments -/// -/// * `_env` - The JNI environment pointer. -/// * `_class` - The Java class calling this function. -/// * `connection_ptr` - A pointer to the `Connection` object. -/// -/// # Returns -/// -/// A `jlong` representing the pointer to the newly created `Cursor` object. -#[no_mangle] -pub extern "system" fn Java_org_github_tursodatabase_limbo_Connection_cursor<'local>( - _env: JNIEnv<'local>, - _class: JClass<'local>, - connection_ptr: jlong, -) -> jlong { - let connection = to_connection(connection_ptr); - let cursor = Cursor { - array_size: 1, - conn: connection.clone(), - description: None, - rowcount: -1, - smt: None, - }; - Box::into_raw(Box::new(cursor)) as jlong -} - -/// Closes the connection and releases the associated resources. -/// -/// This function is called from the Java side to close the connection -/// and free the memory allocated for the `Connection` object. -/// -/// # Arguments -/// -/// * `_env` - The JNI environment pointer. -/// * `_class` - The Java class calling this function. -/// * `connection_ptr` - A pointer to the `Connection` object to be closed. -#[no_mangle] -pub unsafe extern "system" fn Java_org_github_tursodatabase_limbo_Connection_close<'local>( - _env: JNIEnv<'local>, - _class: JClass<'local>, - connection_ptr: jlong, -) { - let _boxed_connection = Box::from_raw(connection_ptr as *mut Connection); -} - -#[no_mangle] -pub extern "system" fn Java_org_github_tursodatabase_limbo_Connection_commit<'local>( - _env: &mut JNIEnv<'local>, - _class: JClass<'local>, - _connection_id: jlong, -) { - unimplemented!() -} - -#[no_mangle] -pub extern "system" fn Java_org_github_tursodatabase_limbo_Connection_rollback<'local>( - _env: &mut JNIEnv<'local>, - _class: JClass<'local>, - _connection_id: jlong, -) { - unimplemented!() -} - -fn to_connection(connection_ptr: jlong) -> &'static mut Connection { - unsafe { &mut *(connection_ptr as *mut Connection) } -} diff --git a/bindings/java/rs_src/cursor.rs b/bindings/java/rs_src/cursor.rs deleted file mode 100644 index da67292d4..000000000 --- a/bindings/java/rs_src/cursor.rs +++ /dev/null @@ -1,240 +0,0 @@ -use crate::connection::Connection; -use crate::errors::ErrorCode; -use crate::utils::row_to_obj_array; -use crate::{eprint_return, eprint_return_null}; -use jni::errors::JniError; -use jni::objects::{JClass, JObject, JString}; -use jni::sys::jlong; -use jni::JNIEnv; -use limbo_core::IO; -use std::fmt::{Debug, Formatter}; -use std::sync::{Arc, Mutex}; - -#[derive(Clone)] -pub struct Cursor { - /// This read/write attribute specifies the number of rows to fetch at a time with `.fetchmany()`. - /// It defaults to `1`, meaning it fetches a single row at a time. - pub(crate) array_size: i64, - - pub(crate) conn: Connection, - - /// The `.description` attribute is a read-only sequence of 7-item, each describing a column in the result set: - /// - /// - `name`: The column's name (always present). - /// - `type_code`: The data type code (always present). - /// - `display_size`: Column's display size (optional). - /// - `internal_size`: Column's internal size (optional). - /// - `precision`: Numeric precision (optional). - /// - `scale`: Numeric scale (optional). - /// - `null_ok`: Indicates if null values are allowed (optional). - /// - /// The `name` and `type_code` fields are mandatory; others default to `None` if not applicable. - /// - /// This attribute is `None` for operations that do not return rows or if no `.execute*()` method has been invoked. - pub(crate) description: Option, - - /// Read-only attribute that provides the number of modified rows for `INSERT`, `UPDATE`, `DELETE`, - /// and `REPLACE` statements; it is `-1` for other statements, including CTE queries. - /// It is only updated by the `execute()` and `executemany()` methods after the statement has run to completion. - /// This means any resulting rows must be fetched for `rowcount` to be updated. - pub(crate) rowcount: i64, - - pub(crate) smt: Option>>, -} - -#[allow(dead_code)] -#[derive(Clone, Debug)] -pub(crate) struct Description { - _name: String, - _type_code: String, - _display_size: Option, - _internal_size: Option, - _precision: Option, - _scale: Option, - _null_ok: Option, -} - -impl Debug for Cursor { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - f.debug_struct("Cursor") - .field("array_size", &self.array_size) - .field("description", &self.description) - .field("rowcount", &self.rowcount) - .finish() - } -} - -/// TODO: we should find a way to handle Error thrown by rust and how to handle those errors in java -#[no_mangle] -#[allow(improper_ctypes_definitions, clippy::arc_with_non_send_sync)] -pub extern "system" fn Java_org_github_tursodatabase_limbo_Cursor_execute<'local>( - mut env: JNIEnv<'local>, - _class: JClass<'local>, - cursor_ptr: jlong, - sql: JString<'local>, -) -> Result<(), JniError> { - let sql: String = env - .get_string(&sql) - .expect("Could not extract query") - .into(); - - let stmt_is_dml = stmt_is_dml(&sql); - if stmt_is_dml { - return eprint_return!( - "DML statements (INSERT/UPDATE/DELETE) are not fully supported in this version", - JniError::Other(ErrorCode::STATEMENT_IS_DML) - ); - } - - let cursor = to_cursor(cursor_ptr); - let conn_lock = match cursor.conn.conn.lock() { - Ok(lock) => lock, - Err(_) => return eprint_return!("Failed to acquire connection lock", JniError::Other(-1)), - }; - - match conn_lock.prepare(&sql) { - Ok(statement) => { - cursor.smt = Some(Arc::new(Mutex::new(statement))); - Ok(()) - } - Err(e) => { - eprint_return!( - &format!("Failed to prepare statement: {:?}", e), - JniError::Other(-1) - ) - } - } -} - -#[no_mangle] -pub extern "system" fn Java_org_github_tursodatabase_limbo_Cursor_fetchOne<'local>( - mut env: JNIEnv<'local>, - _class: JClass<'local>, - cursor_ptr: jlong, -) -> JObject<'local> { - let cursor = to_cursor(cursor_ptr); - - if let Some(smt) = &cursor.smt { - loop { - let mut smt_lock = match smt.lock() { - Ok(lock) => lock, - Err(_) => { - return eprint_return_null!( - "Failed to acquire statement lock", - JniError::Other(-1) - ) - } - }; - - match smt_lock.step() { - Ok(limbo_core::StepResult::Row(row)) => { - return match row_to_obj_array(&mut env, &row) { - Ok(r) => r, - Err(e) => eprint_return_null!(&format!("{:?}", e), JniError::Other(-1)), - } - } - Ok(limbo_core::StepResult::IO) => { - if let Err(e) = cursor.conn.io.run_once() { - return eprint_return_null!( - &format!("IO Error: {:?}", e), - JniError::Other(-1) - ); - } - } - Ok(limbo_core::StepResult::Interrupt) => return JObject::null(), - Ok(limbo_core::StepResult::Done) => return JObject::null(), - Ok(limbo_core::StepResult::Busy) => { - return eprint_return_null!("Busy error", JniError::Other(-1)); - } - Err(e) => { - return eprint_return_null!( - format!("Step error: {:?}", e), - JniError::Other(-1) - ); - } - }; - } - } else { - eprint_return_null!("No statement prepared for execution", JniError::Other(-1)) - } -} - -#[no_mangle] -pub extern "system" fn Java_org_github_tursodatabase_limbo_Cursor_fetchAll<'local>( - mut env: JNIEnv<'local>, - _class: JClass<'local>, - cursor_ptr: jlong, -) -> JObject<'local> { - let cursor = to_cursor(cursor_ptr); - - if let Some(smt) = &cursor.smt { - let mut rows = Vec::new(); - loop { - let mut smt_lock = match smt.lock() { - Ok(lock) => lock, - Err(_) => { - return eprint_return_null!( - "Failed to acquire statement lock", - JniError::Other(-1) - ) - } - }; - - match smt_lock.step() { - Ok(limbo_core::StepResult::Row(row)) => match row_to_obj_array(&mut env, &row) { - Ok(r) => rows.push(r), - Err(e) => return eprint_return_null!(&format!("{:?}", e), JniError::Other(-1)), - }, - Ok(limbo_core::StepResult::IO) => { - if let Err(e) = cursor.conn.io.run_once() { - return eprint_return_null!( - &format!("IO Error: {:?}", e), - JniError::Other(-1) - ); - } - } - Ok(limbo_core::StepResult::Interrupt) => { - return JObject::null(); - } - Ok(limbo_core::StepResult::Done) => { - break; - } - Ok(limbo_core::StepResult::Busy) => { - return eprint_return_null!("Busy error", JniError::Other(-1)); - } - Err(e) => { - return eprint_return_null!( - format!("Step error: {:?}", e), - JniError::Other(-1) - ); - } - }; - } - - let array_class = env - .find_class("[Ljava/lang/Object;") - .expect("Failed to find Object array class"); - let result_array = env - .new_object_array(rows.len() as i32, array_class, JObject::null()) - .expect("Failed to create new object array"); - - for (i, row) in rows.into_iter().enumerate() { - env.set_object_array_element(&result_array, i as i32, row) - .expect("Failed to set object array element"); - } - - result_array.into() - } else { - eprint_return_null!("No statement prepared for execution", JniError::Other(-1)) - } -} - -fn to_cursor(cursor_ptr: jlong) -> &'static mut Cursor { - unsafe { &mut *(cursor_ptr as *mut Cursor) } -} - -fn stmt_is_dml(sql: &str) -> bool { - let sql = sql.trim(); - let sql = sql.to_uppercase(); - sql.starts_with("INSERT") || sql.starts_with("UPDATE") || sql.starts_with("DELETE") -} diff --git a/bindings/java/rs_src/errors.rs b/bindings/java/rs_src/errors.rs index 7924ffb68..0fa2e0276 100644 --- a/bindings/java/rs_src/errors.rs +++ b/bindings/java/rs_src/errors.rs @@ -1,34 +1,113 @@ use jni::errors::{Error, JniError}; +use thiserror::Error; -#[derive(Debug, Clone)] -pub struct CustomError { - pub message: String, +#[derive(Debug, Error)] +pub enum LimboError { + #[error("Custom error: `{0}`")] + CustomError(String), + + #[error("Invalid database pointer")] + InvalidDatabasePointer, + + #[error("Invalid connection pointer")] + InvalidConnectionPointer, + + #[error("JNI Errors: `{0}`")] + JNIErrors(Error), } -/// This struct defines error codes that correspond to the constants defined in the -/// Java package `org.github.tursodatabase.LimboErrorCode`. -/// -/// These error codes are used to handle and represent specific error conditions -/// that may occur within the Rust code and need to be communicated to the Java side. -#[derive(Clone)] -pub struct ErrorCode; - -impl ErrorCode { - // TODO: change CONNECTION_FAILURE_STATEMENT_IS_DML to appropriate error code number - pub const STATEMENT_IS_DML: i32 = -1; +impl From for LimboError { + fn from(_value: limbo_core::LimboError) -> Self { + todo!() + } } -impl From for CustomError { - fn from(value: Error) -> Self { - CustomError { - message: value.to_string(), +impl From for JniError { + fn from(value: LimboError) -> Self { + match value { + LimboError::CustomError(_) + | LimboError::InvalidDatabasePointer + | LimboError::InvalidConnectionPointer + | LimboError::JNIErrors(_) => { + eprintln!("Error occurred: {:?}", value); + JniError::Other(-1) + } } } } -impl From for JniError { - fn from(value: CustomError) -> Self { - eprintln!("Error occurred: {:?}", value.message); - JniError::Other(-1) +impl From for LimboError { + fn from(value: jni::errors::Error) -> Self { + LimboError::JNIErrors(value) } } + +pub type Result = std::result::Result; + +#[allow(dead_code)] +pub const SQLITE_OK: i32 = 0; +#[allow(dead_code)] +pub const SQLITE_ERROR: i32 = 1; +#[allow(dead_code)] +pub const SQLITE_INTERNAL: i32 = 2; +#[allow(dead_code)] +pub const SQLITE_PERM: i32 = 3; +#[allow(dead_code)] +pub const SQLITE_ABORT: i32 = 4; +#[allow(dead_code)] +pub const SQLITE_BUSY: i32 = 5; +#[allow(dead_code)] +pub const SQLITE_LOCKED: i32 = 6; +#[allow(dead_code)] +pub const SQLITE_NOMEM: i32 = 7; +#[allow(dead_code)] +pub const SQLITE_READONLY: i32 = 8; +#[allow(dead_code)] +pub const SQLITE_INTERRUPT: i32 = 9; +#[allow(dead_code)] +pub const SQLITE_IOERR: i32 = 10; +#[allow(dead_code)] +pub const SQLITE_CORRUPT: i32 = 11; +#[allow(dead_code)] +pub const SQLITE_NOTFOUND: i32 = 12; +#[allow(dead_code)] +pub const SQLITE_FULL: i32 = 13; +#[allow(dead_code)] +pub const SQLITE_CANTOPEN: i32 = 14; +#[allow(dead_code)] +pub const SQLITE_PROTOCOL: i32 = 15; +#[allow(dead_code)] +pub const SQLITE_EMPTY: i32 = 16; +#[allow(dead_code)] +pub const SQLITE_SCHEMA: i32 = 17; +#[allow(dead_code)] +pub const SQLITE_TOOBIG: i32 = 18; +#[allow(dead_code)] +pub const SQLITE_CONSTRAINT: i32 = 19; +#[allow(dead_code)] +pub const SQLITE_MISMATCH: i32 = 20; +#[allow(dead_code)] +pub const SQLITE_MISUSE: i32 = 21; +#[allow(dead_code)] +pub const SQLITE_NOLFS: i32 = 22; +#[allow(dead_code)] +pub const SQLITE_AUTH: i32 = 23; +#[allow(dead_code)] +pub const SQLITE_ROW: i32 = 100; +#[allow(dead_code)] +pub const SQLITE_DONE: i32 = 101; + +#[allow(dead_code)] +pub const SQLITE_INTEGER: i32 = 1; +#[allow(dead_code)] +pub const SQLITE_FLOAT: i32 = 2; +#[allow(dead_code)] +pub const SQLITE_TEXT: i32 = 3; +#[allow(dead_code)] +pub const SQLITE_BLOB: i32 = 4; +#[allow(dead_code)] +pub const SQLITE_NULL: i32 = 5; + +pub const LIMBO_FAILED_TO_PARSE_BYTE_ARRAY: i32 = 1100; +pub const LIMBO_FAILED_TO_PREPARE_STATEMENT: i32 = 1200; +pub const LIMBO_ETC: i32 = 9999; diff --git a/bindings/java/rs_src/lib.rs b/bindings/java/rs_src/lib.rs index 4dfadd743..877111cbd 100644 --- a/bindings/java/rs_src/lib.rs +++ b/bindings/java/rs_src/lib.rs @@ -1,6 +1,5 @@ -mod connection; -mod cursor; mod errors; +mod limbo_connection; mod limbo_db; -mod macros; +mod limbo_statement; mod utils; diff --git a/bindings/java/rs_src/limbo_connection.rs b/bindings/java/rs_src/limbo_connection.rs new file mode 100644 index 000000000..1399d8b42 --- /dev/null +++ b/bindings/java/rs_src/limbo_connection.rs @@ -0,0 +1,83 @@ +use crate::errors::{ + LimboError, Result, LIMBO_ETC, LIMBO_FAILED_TO_PARSE_BYTE_ARRAY, + LIMBO_FAILED_TO_PREPARE_STATEMENT, +}; +use crate::limbo_statement::LimboStatement; +use crate::utils::{set_err_msg_and_throw_exception, utf8_byte_arr_to_str}; +use jni::objects::{JByteArray, JObject}; +use jni::sys::jlong; +use jni::JNIEnv; +use limbo_core::Connection; +use std::rc::Rc; + +#[derive(Clone)] +#[allow(dead_code)] +pub struct LimboConnection { + pub(crate) conn: Rc, + pub(crate) io: Rc, +} + +impl LimboConnection { + pub fn new(conn: Rc, io: Rc) -> Self { + LimboConnection { conn, io } + } + + pub fn to_ptr(self) -> jlong { + Box::into_raw(Box::new(self)) as jlong + } + + #[allow(dead_code)] + pub fn drop(ptr: jlong) { + let _boxed = unsafe { Box::from_raw(ptr as *mut LimboConnection) }; + } +} + +pub fn to_limbo_connection(ptr: jlong) -> Result<&'static mut LimboConnection> { + if ptr == 0 { + Err(LimboError::InvalidConnectionPointer) + } else { + unsafe { Ok(&mut *(ptr as *mut LimboConnection)) } + } +} + +#[no_mangle] +pub extern "system" fn Java_org_github_tursodatabase_core_LimboConnection_prepareUtf8<'local>( + mut env: JNIEnv<'local>, + obj: JObject<'local>, + connection_ptr: jlong, + sql_bytes: JByteArray<'local>, +) -> jlong { + let connection = match to_limbo_connection(connection_ptr) { + Ok(conn) => conn, + Err(e) => { + set_err_msg_and_throw_exception(&mut env, obj, LIMBO_ETC, e.to_string()); + return 0; + } + }; + + let sql = match utf8_byte_arr_to_str(&env, sql_bytes) { + Ok(sql) => sql, + Err(e) => { + set_err_msg_and_throw_exception( + &mut env, + obj, + LIMBO_FAILED_TO_PARSE_BYTE_ARRAY, + e.to_string(), + ); + return 0; + } + }; + + match connection.conn.prepare(sql) { + Ok(stmt) => LimboStatement::new(stmt).to_ptr(), + Err(e) => { + set_err_msg_and_throw_exception( + &mut env, + obj, + LIMBO_FAILED_TO_PREPARE_STATEMENT, + e.to_string(), + ); + 0 + } + } +} diff --git a/bindings/java/rs_src/limbo_db.rs b/bindings/java/rs_src/limbo_db.rs index 4b589ba9d..09d8afa75 100644 --- a/bindings/java/rs_src/limbo_db.rs +++ b/bindings/java/rs_src/limbo_db.rs @@ -1,40 +1,69 @@ +use crate::errors::{LimboError, Result, LIMBO_ETC}; +use crate::limbo_connection::LimboConnection; +use crate::utils::set_err_msg_and_throw_exception; use jni::objects::{JByteArray, JObject}; use jni::sys::{jint, jlong}; use jni::JNIEnv; use limbo_core::Database; +use std::rc::Rc; use std::sync::Arc; -const ERROR_CODE_ETC: i32 = 9999; +struct LimboDB { + db: Arc, +} + +impl LimboDB { + pub fn new(db: Arc) -> Self { + LimboDB { db } + } + + pub fn to_ptr(self) -> jlong { + Box::into_raw(Box::new(self)) as jlong + } + + #[allow(dead_code)] + pub fn drop(ptr: jlong) { + let _boxed = unsafe { Box::from_raw(ptr as *mut LimboDB) }; + } +} + +fn to_limbo_db(ptr: jlong) -> Result<&'static mut LimboDB> { + if ptr == 0 { + Err(LimboError::InvalidDatabasePointer) + } else { + unsafe { Ok(&mut *(ptr as *mut LimboDB)) } + } +} #[no_mangle] #[allow(clippy::arc_with_non_send_sync)] pub extern "system" fn Java_org_github_tursodatabase_core_LimboDB_openUtf8<'local>( mut env: JNIEnv<'local>, obj: JObject<'local>, - file_name_byte_arr: JByteArray<'local>, + file_path_byte_arr: JByteArray<'local>, _open_flags: jint, ) -> jlong { let io = match limbo_core::PlatformIO::new() { Ok(io) => Arc::new(io), Err(e) => { - set_err_msg_and_throw_exception(&mut env, obj, ERROR_CODE_ETC, e.to_string()); + set_err_msg_and_throw_exception(&mut env, obj, LIMBO_ETC, e.to_string()); return -1; } }; let path = match env - .convert_byte_array(file_name_byte_arr) + .convert_byte_array(file_path_byte_arr) .map_err(|e| e.to_string()) { Ok(bytes) => match String::from_utf8(bytes) { Ok(s) => s, Err(e) => { - set_err_msg_and_throw_exception(&mut env, obj, ERROR_CODE_ETC, e.to_string()); + set_err_msg_and_throw_exception(&mut env, obj, LIMBO_ETC, e.to_string()); return -1; } }, Err(e) => { - set_err_msg_and_throw_exception(&mut env, obj, ERROR_CODE_ETC, e.to_string()); + set_err_msg_and_throw_exception(&mut env, obj, LIMBO_ETC, e.to_string()); return -1; } }; @@ -42,12 +71,65 @@ pub extern "system" fn Java_org_github_tursodatabase_core_LimboDB_openUtf8<'loca let db = match Database::open_file(io.clone(), &path) { Ok(db) => db, Err(e) => { - set_err_msg_and_throw_exception(&mut env, obj, ERROR_CODE_ETC, e.to_string()); + set_err_msg_and_throw_exception(&mut env, obj, LIMBO_ETC, e.to_string()); return -1; } }; - Box::into_raw(Box::new(db)) as jlong + LimboDB::new(db).to_ptr() +} + +#[no_mangle] +pub extern "system" fn Java_org_github_tursodatabase_core_LimboDB_connect0<'local>( + mut env: JNIEnv<'local>, + obj: JObject<'local>, + file_path_byte_arr: JByteArray<'local>, + db_pointer: jlong, +) -> jlong { + let db = match to_limbo_db(db_pointer) { + Ok(db) => db, + Err(e) => { + set_err_msg_and_throw_exception(&mut env, obj, LIMBO_ETC, e.to_string()); + return 0; + } + }; + + let path = match env + .convert_byte_array(file_path_byte_arr) + .map_err(|e| e.to_string()) + { + Ok(bytes) => match String::from_utf8(bytes) { + Ok(s) => s, + Err(e) => { + set_err_msg_and_throw_exception(&mut env, obj, LIMBO_ETC, e.to_string()); + return 0; + } + }, + Err(e) => { + set_err_msg_and_throw_exception(&mut env, obj, LIMBO_ETC, e.to_string()); + return 0; + } + }; + + let io: Rc = match path.as_str() { + ":memory:" => match limbo_core::MemoryIO::new() { + Ok(io) => Rc::new(io), + Err(e) => { + set_err_msg_and_throw_exception(&mut env, obj, LIMBO_ETC, e.to_string()); + return 0; + } + }, + _ => match limbo_core::PlatformIO::new() { + Ok(io) => Rc::new(io), + Err(e) => { + set_err_msg_and_throw_exception(&mut env, obj, LIMBO_ETC, e.to_string()); + return 0; + } + }, + }; + let conn = LimboConnection::new(db.db.connect(), io); + + conn.to_ptr() } #[no_mangle] @@ -63,27 +145,3 @@ pub extern "system" fn Java_org_github_tursodatabase_core_LimboDB_throwJavaExcep "throw java exception".to_string(), ); } - -fn set_err_msg_and_throw_exception<'local>( - env: &mut JNIEnv<'local>, - obj: JObject<'local>, - err_code: i32, - err_msg: String, -) { - let error_message_bytes = env - .byte_array_from_slice(err_msg.as_bytes()) - .expect("Failed to convert to byte array"); - match env.call_method( - obj, - "throwLimboException", - "(I[B)V", - &[err_code.into(), (&error_message_bytes).into()], - ) { - Ok(_) => { - // do nothing because above method will always return Err - } - Err(_e) => { - // do nothing because our java app will handle Err - } - } -} diff --git a/bindings/java/rs_src/limbo_statement.rs b/bindings/java/rs_src/limbo_statement.rs new file mode 100644 index 000000000..78eff1fc4 --- /dev/null +++ b/bindings/java/rs_src/limbo_statement.rs @@ -0,0 +1,98 @@ +use crate::errors::Result; +use crate::errors::{LimboError, LIMBO_ETC}; +use crate::utils::set_err_msg_and_throw_exception; +use jni::objects::{JObject, JValue}; +use jni::sys::jlong; +use jni::JNIEnv; +use limbo_core::{Statement, StepResult}; + +pub struct LimboStatement { + pub(crate) stmt: Statement, +} + +impl LimboStatement { + pub fn new(stmt: Statement) -> Self { + LimboStatement { stmt } + } + + pub fn to_ptr(self) -> jlong { + Box::into_raw(Box::new(self)) as jlong + } + + #[allow(dead_code)] + pub fn drop(ptr: jlong) { + let _boxed = unsafe { Box::from_raw(ptr as *mut LimboStatement) }; + } +} + +pub fn to_limbo_statement(ptr: jlong) -> Result<&'static mut LimboStatement> { + if ptr == 0 { + Err(LimboError::InvalidConnectionPointer) + } else { + unsafe { Ok(&mut *(ptr as *mut LimboStatement)) } + } +} + +#[no_mangle] +pub extern "system" fn Java_org_github_tursodatabase_core_LimboStatement_step<'local>( + mut env: JNIEnv<'local>, + obj: JObject<'local>, + stmt_ptr: jlong, +) -> JObject<'local> { + let stmt = match to_limbo_statement(stmt_ptr) { + Ok(stmt) => stmt, + Err(e) => { + set_err_msg_and_throw_exception(&mut env, obj, LIMBO_ETC, e.to_string()); + + return JObject::null(); + } + }; + + match stmt.stmt.step() { + Ok(StepResult::Row(row)) => match row_to_obj_array(&mut env, &row) { + Ok(row) => row, + Err(e) => { + set_err_msg_and_throw_exception(&mut env, obj, LIMBO_ETC, e.to_string()); + + JObject::null() + } + }, + Ok(StepResult::IO) => match env.new_object_array(0, "java/lang/Object", JObject::null()) { + Ok(row) => row.into(), + Err(e) => { + set_err_msg_and_throw_exception(&mut env, obj, LIMBO_ETC, e.to_string()); + + JObject::null() + } + }, + _ => JObject::null(), + } +} + +#[allow(dead_code)] +fn row_to_obj_array<'local>( + env: &mut JNIEnv<'local>, + row: &limbo_core::Row, +) -> Result> { + let obj_array = + env.new_object_array(row.values.len() as i32, "java/lang/Object", JObject::null())?; + + for (i, value) in row.values.iter().enumerate() { + let obj = match value { + limbo_core::Value::Null => JObject::null(), + limbo_core::Value::Integer(i) => { + env.new_object("java/lang/Long", "(J)V", &[JValue::Long(*i)])? + } + limbo_core::Value::Float(f) => { + env.new_object("java/lang/Double", "(D)V", &[JValue::Double(*f)])? + } + limbo_core::Value::Text(s) => env.new_string(s)?.into(), + limbo_core::Value::Blob(b) => env.byte_array_from_slice(b)?.into(), + }; + if let Err(e) = env.set_object_array_element(&obj_array, i as i32, obj) { + eprintln!("Error on parsing row: {:?}", e); + } + } + + Ok(obj_array.into()) +} diff --git a/bindings/java/rs_src/macros.rs b/bindings/java/rs_src/macros.rs deleted file mode 100644 index 967834f9f..000000000 --- a/bindings/java/rs_src/macros.rs +++ /dev/null @@ -1,16 +0,0 @@ -// bindings/java/src/macros.rs -#[macro_export] -macro_rules! eprint_return { - ($log:expr, $error:expr) => {{ - eprintln!("{}", $log); - Err($error) - }}; -} - -#[macro_export] -macro_rules! eprint_return_null { - ($log:expr, $error:expr) => {{ - eprintln!("{}", $log); - JObject::null() - }}; -} diff --git a/bindings/java/rs_src/utils.rs b/bindings/java/rs_src/utils.rs index 4fde084fc..79c597b39 100644 --- a/bindings/java/rs_src/utils.rs +++ b/bindings/java/rs_src/utils.rs @@ -1,30 +1,55 @@ -use crate::errors::CustomError; -use jni::objects::{JObject, JValue}; +use crate::errors::LimboError; +use jni::objects::{JByteArray, JObject}; use jni::JNIEnv; -pub(crate) fn row_to_obj_array<'local>( - env: &mut JNIEnv<'local>, - row: &limbo_core::Row, -) -> Result, CustomError> { - let obj_array = - env.new_object_array(row.values.len() as i32, "java/lang/Object", JObject::null())?; +pub(crate) fn utf8_byte_arr_to_str( + env: &JNIEnv, + bytes: JByteArray, +) -> crate::errors::Result { + let bytes = env + .convert_byte_array(bytes) + .map_err(|_| LimboError::CustomError("Failed to retrieve bytes".to_string()))?; + let str = String::from_utf8(bytes).map_err(|_| { + LimboError::CustomError("Failed to convert utf8 byte array into string".to_string()) + })?; + Ok(str) +} - for (i, value) in row.values.iter().enumerate() { - let obj = match value { - limbo_core::Value::Null => JObject::null(), - limbo_core::Value::Integer(i) => { - env.new_object("java/lang/Long", "(J)V", &[JValue::Long(*i)])? - } - limbo_core::Value::Float(f) => { - env.new_object("java/lang/Double", "(D)V", &[JValue::Double(*f)])? - } - limbo_core::Value::Text(s) => env.new_string(s)?.into(), - limbo_core::Value::Blob(b) => env.byte_array_from_slice(b)?.into(), - }; - if let Err(e) = env.set_object_array_element(&obj_array, i as i32, obj) { - eprintln!("Error on parsing row: {:?}", e); +/// Sets the error message and throws a Java exception. +/// +/// This function converts the provided error message to a byte array and calls the +/// `throwLimboException` method on the provided Java object to throw an exception. +/// +/// # Parameters +/// - `env`: The JNI environment. +/// - `obj`: The Java object on which the exception will be thrown. +/// - `err_code`: The error code corresponding to the exception. Refer to `org.github.tursodatabase.core.Codes` for the list of error codes. +/// - `err_msg`: The error message to be included in the exception. +/// +/// # Example +/// ```rust +/// set_err_msg_and_throw_exception(env, obj, Codes::SQLITE_ERROR, "An error occurred".to_string()); +/// ``` +pub fn set_err_msg_and_throw_exception<'local>( + env: &mut JNIEnv<'local>, + obj: JObject<'local>, + err_code: i32, + err_msg: String, +) { + let error_message_bytes = env + .byte_array_from_slice(err_msg.as_bytes()) + .expect("Failed to convert to byte array"); + match env.call_method( + obj, + "throwLimboException", + "(I[B)V", + &[err_code.into(), (&error_message_bytes).into()], + ) { + Ok(_) => { + // do nothing because above method will always return Err + } + Err(_e) => { + // do nothing because our java app will handle Err } } - - Ok(obj_array.into()) } diff --git a/bindings/java/src/main/java/org/github/tursodatabase/JDBC.java b/bindings/java/src/main/java/org/github/tursodatabase/JDBC.java index 0b2196058..bb46fb05e 100644 --- a/bindings/java/src/main/java/org/github/tursodatabase/JDBC.java +++ b/bindings/java/src/main/java/org/github/tursodatabase/JDBC.java @@ -2,6 +2,7 @@ package org.github.tursodatabase; import org.github.tursodatabase.annotations.Nullable; import org.github.tursodatabase.annotations.SkipNullableCheck; +import org.github.tursodatabase.core.LimboConnection; import org.github.tursodatabase.jdbc4.JDBC4Connection; import java.sql.*; diff --git a/bindings/java/src/main/java/org/github/tursodatabase/LimboErrorCode.java b/bindings/java/src/main/java/org/github/tursodatabase/LimboErrorCode.java index 0c65ba04f..d2450d266 100644 --- a/bindings/java/src/main/java/org/github/tursodatabase/LimboErrorCode.java +++ b/bindings/java/src/main/java/org/github/tursodatabase/LimboErrorCode.java @@ -1,8 +1,47 @@ package org.github.tursodatabase; +import org.github.tursodatabase.core.SqliteCode; + +/** + * Limbo error code. Superset of SQLite3 error code. + */ public enum LimboErrorCode { + SQLITE_OK(SqliteCode.SQLITE_OK, "Successful result"), + SQLITE_ERROR(SqliteCode.SQLITE_ERROR, "SQL error or missing database"), + SQLITE_INTERNAL(SqliteCode.SQLITE_INTERNAL, "An internal logic error in SQLite"), + SQLITE_PERM(SqliteCode.SQLITE_PERM, "Access permission denied"), + SQLITE_ABORT(SqliteCode.SQLITE_ABORT, "Callback routine requested an abort"), + SQLITE_BUSY(SqliteCode.SQLITE_BUSY, "The database file is locked"), + SQLITE_LOCKED(SqliteCode.SQLITE_LOCKED, "A table in the database is locked"), + SQLITE_NOMEM(SqliteCode.SQLITE_NOMEM, "A malloc() failed"), + SQLITE_READONLY(SqliteCode.SQLITE_READONLY, "Attempt to write a readonly database"), + SQLITE_INTERRUPT(SqliteCode.SQLITE_INTERRUPT, "Operation terminated by sqlite_interrupt()"), + SQLITE_IOERR(SqliteCode.SQLITE_IOERR, "Some kind of disk I/O error occurred"), + SQLITE_CORRUPT(SqliteCode.SQLITE_CORRUPT, "The database disk image is malformed"), + SQLITE_NOTFOUND(SqliteCode.SQLITE_NOTFOUND, "(Internal Only) Table or record not found"), + SQLITE_FULL(SqliteCode.SQLITE_FULL, "Insertion failed because database is full"), + SQLITE_CANTOPEN(SqliteCode.SQLITE_CANTOPEN, "Unable to open the database file"), + SQLITE_PROTOCOL(SqliteCode.SQLITE_PROTOCOL, "Database lock protocol error"), + SQLITE_EMPTY(SqliteCode.SQLITE_EMPTY, "(Internal Only) Database table is empty"), + SQLITE_SCHEMA(SqliteCode.SQLITE_SCHEMA, "The database schema changed"), + SQLITE_TOOBIG(SqliteCode.SQLITE_TOOBIG, "Too much data for one row of a table"), + SQLITE_CONSTRAINT(SqliteCode.SQLITE_CONSTRAINT, "Abort due to constraint violation"), + SQLITE_MISMATCH(SqliteCode.SQLITE_MISMATCH, "Data type mismatch"), + SQLITE_MISUSE(SqliteCode.SQLITE_MISUSE, "Library used incorrectly"), + SQLITE_NOLFS(SqliteCode.SQLITE_NOLFS, "Uses OS features not supported on host"), + SQLITE_AUTH(SqliteCode.SQLITE_AUTH, "Authorization denied"), + SQLITE_ROW(SqliteCode.SQLITE_ROW, "sqlite_step() has another row ready"), + SQLITE_DONE(SqliteCode.SQLITE_DONE, "sqlite_step() has finished executing"), + SQLITE_INTEGER(SqliteCode.SQLITE_INTEGER, "Integer type"), + SQLITE_FLOAT(SqliteCode.SQLITE_FLOAT, "Float type"), + SQLITE_TEXT(SqliteCode.SQLITE_TEXT, "Text type"), + SQLITE_BLOB(SqliteCode.SQLITE_BLOB, "Blob type"), + SQLITE_NULL(SqliteCode.SQLITE_NULL, "Null type"), + UNKNOWN_ERROR(-1, "Unknown error"), - ETC(9999, "Unclassified error"); + LIMBO_FAILED_TO_PARSE_BYTE_ARRAY(1100, "Failed to parse ut8 byte array"), + LIMBO_FAILED_TO_PREPARE_STATEMENT(1200, "Failed to prepare statement"), + LIMBO_ETC(9999, "Unclassified error"); public final int code; public final String message; diff --git a/bindings/java/src/main/java/org/github/tursodatabase/core/AbstractDB.java b/bindings/java/src/main/java/org/github/tursodatabase/core/AbstractDB.java index 2e37dcbab..f655e6dcf 100644 --- a/bindings/java/src/main/java/org/github/tursodatabase/core/AbstractDB.java +++ b/bindings/java/src/main/java/org/github/tursodatabase/core/AbstractDB.java @@ -11,13 +11,13 @@ import java.util.concurrent.atomic.AtomicBoolean; * differences between the JDBC specification and the Limbo API. */ public abstract class AbstractDB { - private final String url; - private final String fileName; + protected final String url; + protected final String filePath; private final AtomicBoolean closed = new AtomicBoolean(true); - public AbstractDB(String url, String filaName) { + public AbstractDB(String url, String filePath) { this.url = url; - this.fileName = filaName; + this.filePath = filePath; } public boolean isClosed() { @@ -29,18 +29,6 @@ public abstract class AbstractDB { */ public abstract void interrupt() throws SQLException; - /** - * Executes an SQL statement. - * - * @param sql SQL statement to be executed. - * @param autoCommit Whether to auto-commit the transaction. - * @throws SQLException if a database access error occurs. - */ - public final synchronized void exec(String sql, boolean autoCommit) throws SQLException { - // TODO: add implementation - throw new SQLFeatureNotSupportedException(); - } - /** * Creates an SQLite interface to a database for the given connection. * @@ -48,7 +36,7 @@ public abstract class AbstractDB { * @throws SQLException if a database access error occurs. */ public final synchronized void open(int openFlags) throws SQLException { - open0(fileName, openFlags); + open0(filePath, openFlags); } protected abstract void open0(String fileName, int openFlags) throws SQLException; @@ -65,28 +53,11 @@ public abstract class AbstractDB { } /** - * Compiles an SQL statement. + * Connects to a database. * - * @param stmt The SQL statement to compile. - * @throws SQLException if a database access error occurs. + * @return Pointer to the connection. */ - public final synchronized void prepare(CoreStatement stmt) throws SQLException { - // TODO: add implementation - throw new SQLFeatureNotSupportedException(); - } - - /** - * Destroys a statement. - * - * @param safePtr the pointer wrapper to remove from internal structures. - * @param ptr the raw pointer to free. - * @return Result Codes - * @throws SQLException if a database access error occurs. - */ - public synchronized int finalize(SafeStmtPtr safePtr, long ptr) throws SQLException { - // TODO: add implementation - throw new SQLFeatureNotSupportedException(); - } + public abstract long connect() throws SQLException; /** * Creates an SQLite interface to a database with the provided open flags. @@ -104,68 +75,4 @@ public abstract class AbstractDB { * @throws SQLException if a database access error occurs. */ protected abstract void close0() throws SQLException; - - /** - * Compiles, evaluates, executes and commits an SQL statement. - * - * @param sql An SQL statement. - * @return Result code. - * @throws SQLException if a database access error occurs. - */ - public abstract int exec(String sql) throws SQLException; - - /** - * Compiles an SQL statement. - * - * @param sql An SQL statement. - * @return A SafeStmtPtr object. - * @throws SQLException if a database access error occurs. - */ - protected abstract SafeStmtPtr prepare(String sql) throws SQLException; - - /** - * Destroys a prepared statement. - * - * @param stmt Pointer to the statement pointer. - * @return Result code. - * @throws SQLException if a database access error occurs. - */ - protected abstract int finalize(long stmt) throws SQLException; - - /** - * Evaluates a statement. - * - * @param stmt Pointer to the statement. - * @return Result code. - * @throws SQLException if a database access error occurs. - */ - public abstract int step(long stmt) throws SQLException; - - /** - * Executes a statement with the provided parameters. - * - * @param stmt Stmt object. - * @param vals Array of parameter values. - * @return True if a row of ResultSet is ready; false otherwise. - * @throws SQLException if a database access error occurs. - * @see SQLite Exec - */ - public final synchronized boolean execute(CoreStatement stmt, Object[] vals) throws SQLException { - throw new SQLFeatureNotSupportedException(); - } - - /** - * Executes an SQL INSERT, UPDATE or DELETE statement with the Stmt object and an array of - * parameter values of the SQL statement. - * - * @param stmt Stmt object. - * @param vals Array of parameter values. - * @return Number of database rows that were changed or inserted or deleted by the most recently - * completed SQL. - * @throws SQLException if a database access error occurs. - */ - public final synchronized long executeUpdate(CoreStatement stmt, Object[] vals) throws SQLException { - // TODO: add implementation - throw new SQLFeatureNotSupportedException(); - } } diff --git a/bindings/java/src/main/java/org/github/tursodatabase/core/CoreStatement.java b/bindings/java/src/main/java/org/github/tursodatabase/core/CoreStatement.java deleted file mode 100644 index 98dd89ab3..000000000 --- a/bindings/java/src/main/java/org/github/tursodatabase/core/CoreStatement.java +++ /dev/null @@ -1,26 +0,0 @@ -package org.github.tursodatabase.core; - -import org.github.tursodatabase.LimboConnection; - -import java.sql.SQLException; - -public abstract class CoreStatement { - - private final LimboConnection connection; - - protected CoreStatement(LimboConnection connection) { - this.connection = connection; - } - - protected void internalClose() throws SQLException { - // TODO - } - - protected void clearGeneratedKeys() throws SQLException { - // TODO - } - - protected void updateGeneratedKeys() throws SQLException { - // TODO - } -} diff --git a/bindings/java/src/main/java/org/github/tursodatabase/LimboConnection.java b/bindings/java/src/main/java/org/github/tursodatabase/core/LimboConnection.java similarity index 51% rename from bindings/java/src/main/java/org/github/tursodatabase/LimboConnection.java rename to bindings/java/src/main/java/org/github/tursodatabase/core/LimboConnection.java index de1a5228e..d6c3ab6af 100644 --- a/bindings/java/src/main/java/org/github/tursodatabase/LimboConnection.java +++ b/bindings/java/src/main/java/org/github/tursodatabase/core/LimboConnection.java @@ -1,36 +1,38 @@ -package org.github.tursodatabase; +package org.github.tursodatabase.core; -import org.github.tursodatabase.core.AbstractDB; -import org.github.tursodatabase.core.LimboDB; +import org.github.tursodatabase.annotations.NativeInvocation; +import org.github.tursodatabase.utils.LimboExceptionUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.sql.Connection; import java.sql.ResultSet; import java.sql.SQLException; import java.util.Properties; -public abstract class LimboConnection implements Connection { +import static org.github.tursodatabase.utils.ByteArrayUtils.stringToUtf8ByteArray; +public abstract class LimboConnection implements Connection { + private static final Logger logger = LoggerFactory.getLogger(LimboConnection.class); + + private final long connectionPtr; private final AbstractDB database; - public LimboConnection(AbstractDB database) { - this.database = database; - } - - public LimboConnection(String url, String fileName) throws SQLException { - this(url, fileName, new Properties()); + public LimboConnection(String url, String filePath) throws SQLException { + this(url, filePath, new Properties()); } /** - * Creates a connection to limbo database. + * Creates a connection to limbo database * * @param url e.g. "jdbc:sqlite:fileName" - * @param fileName path to file + * @param filePath path to file */ - public LimboConnection(String url, String fileName, Properties properties) throws SQLException { + public LimboConnection(String url, String filePath, Properties properties) throws SQLException { AbstractDB db = null; try { - db = open(url, fileName, properties); + db = open(url, filePath, properties); } catch (Throwable t) { try { if (db != null) { @@ -44,23 +46,11 @@ public abstract class LimboConnection implements Connection { } this.database = db; + this.connectionPtr = db.connect(); } - private static AbstractDB open(String url, String fileName, Properties properties) throws SQLException { - if (fileName.isEmpty()) { - throw new IllegalArgumentException("fileName should not be empty"); - } - - final AbstractDB database; - try { - LimboDB.load(); - database = LimboDB.create(url, fileName); - } catch (Exception e) { - throw new SQLException("Error opening connection", e); - } - - database.open(0); - return database; + private static AbstractDB open(String url, String filePath, Properties properties) throws SQLException { + return LimboDBFactory.open(url, filePath, properties); } protected void checkOpen() throws SQLException { @@ -78,7 +68,38 @@ public abstract class LimboConnection implements Connection { return database.isClosed(); } + public AbstractDB getDatabase() { + return database; + } + + /** + * Compiles an SQL statement. + * + * @param sql An SQL statement. + * @return Pointer to statement. + * @throws SQLException if a database access error occurs. + */ + public long prepare(String sql) throws SQLException { + logger.trace("DriverManager [{}] [SQLite EXEC] {}", Thread.currentThread().getName(), sql); + byte[] sqlBytes = stringToUtf8ByteArray(sql); + if (sqlBytes == null) { + throw new SQLException("Failed to convert " + sql + " into bytes"); + } + return prepareUtf8(connectionPtr, sqlBytes); + } + + private native long prepareUtf8(long connectionPtr, byte[] sqlUtf8) throws SQLException; + + /** + * @return busy timeout in milliseconds. + */ + public int getBusyTimeout() { + // TODO: add support for busyTimeout + return 0; + } + // TODO: check whether this is still valid for limbo + /** * Checks whether the type, concurrency, and holdability settings for a {@link ResultSet} are * supported by the SQLite interface. Supported settings are: @@ -101,4 +122,19 @@ public abstract class LimboConnection implements Connection { if (resultSetHoldability != ResultSet.CLOSE_CURSORS_AT_COMMIT) throw new SQLException("SQLite only supports closing cursors at commit"); } + + public void setBusyTimeout(int busyTimeout) { + // TODO: add support for busy timeout + } + + /** + * Throws formatted SQLException with error code and message. + * + * @param errorCode Error code. + * @param errorMessageBytes Error message. + */ + @NativeInvocation + private void throwLimboException(int errorCode, byte[] errorMessageBytes) throws SQLException { + LimboExceptionUtils.throwLimboException(errorCode, errorMessageBytes); + } } diff --git a/bindings/java/src/main/java/org/github/tursodatabase/core/LimboDB.java b/bindings/java/src/main/java/org/github/tursodatabase/core/LimboDB.java index da829da63..6229156ea 100644 --- a/bindings/java/src/main/java/org/github/tursodatabase/core/LimboDB.java +++ b/bindings/java/src/main/java/org/github/tursodatabase/core/LimboDB.java @@ -4,23 +4,27 @@ package org.github.tursodatabase.core; import org.github.tursodatabase.LimboErrorCode; import org.github.tursodatabase.annotations.NativeInvocation; import org.github.tursodatabase.annotations.VisibleForTesting; -import org.github.tursodatabase.annotations.Nullable; -import org.github.tursodatabase.exceptions.LimboException; +import org.github.tursodatabase.utils.LimboExceptionUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; -import java.nio.charset.StandardCharsets; import java.sql.SQLException; import java.sql.SQLFeatureNotSupportedException; +import java.util.concurrent.locks.ReentrantLock; + +import static org.github.tursodatabase.utils.ByteArrayUtils.stringToUtf8ByteArray; /** * This class provides a thin JNI layer over the SQLite3 C API. */ public final class LimboDB extends AbstractDB { - + private static final Logger logger = LoggerFactory.getLogger(LimboDB.class); // Pointer to database instance - private long dbPtr; + private long dbPointer; private boolean isOpen; private static boolean isLoaded; + private ReentrantLock dbLock = new ReentrantLock(); static { if ("The Android Project".equals(System.getProperty("java.vm.vendor"))) { @@ -46,68 +50,59 @@ public final class LimboDB extends AbstractDB { /** * @param url e.g. "jdbc:sqlite:fileName - * @param fileName e.g. path to file + * @param filePath e.g. path to file */ - public static LimboDB create(String url, String fileName) throws SQLException { - return new LimboDB(url, fileName); + public static LimboDB create(String url, String filePath) throws SQLException { + return new LimboDB(url, filePath); } // TODO: receive config as argument - private LimboDB(String url, String fileName) { - super(url, fileName); + private LimboDB(String url, String filePath) { + super(url, filePath); } // WRAPPER FUNCTIONS //////////////////////////////////////////// // TODO: add support for JNI @Override - protected synchronized native long openUtf8(byte[] file, int openFlags) throws SQLException; + protected native long openUtf8(byte[] file, int openFlags) throws SQLException; // TODO: add support for JNI @Override - protected synchronized native void close0() throws SQLException; - - @Override - public synchronized int exec(String sql) throws SQLException { - // TODO: add implementation - throw new SQLFeatureNotSupportedException(); - } + protected native void close0() throws SQLException; // TODO: add support for JNI - synchronized native int execUtf8(byte[] sqlUtf8) throws SQLException; + native int execUtf8(byte[] sqlUtf8) throws SQLException; // TODO: add support for JNI @Override public native void interrupt(); @Override - protected void open0(String fileName, int openFlags) throws SQLException { + protected void open0(String filePath, int openFlags) throws SQLException { if (isOpen) { - throw buildLimboException(LimboErrorCode.ETC.code, "Already opened"); + throw LimboExceptionUtils.buildLimboException(LimboErrorCode.LIMBO_ETC.code, "Already opened"); } - byte[] fileNameBytes = stringToUtf8ByteArray(fileName); - if (fileNameBytes == null) { - throw buildLimboException(LimboErrorCode.ETC.code, "File name cannot be converted to byteArray. File name: " + fileName); + byte[] filePathBytes = stringToUtf8ByteArray(filePath); + if (filePathBytes == null) { + throw LimboExceptionUtils.buildLimboException(LimboErrorCode.LIMBO_ETC.code, "File path cannot be converted to byteArray. File name: " + filePath); } - dbPtr = openUtf8(fileNameBytes, openFlags); + dbPointer = openUtf8(filePathBytes, openFlags); isOpen = true; } @Override - protected synchronized SafeStmtPtr prepare(String sql) throws SQLException { - // TODO: add implementation - throw new SQLFeatureNotSupportedException(); + public long connect() throws SQLException { + byte[] filePathBytes = stringToUtf8ByteArray(filePath); + if (filePathBytes == null) { + throw LimboExceptionUtils.buildLimboException(LimboErrorCode.LIMBO_ETC.code, "File path cannot be converted to byteArray. File name: " + filePath); + } + return connect0(filePathBytes, dbPointer); } - // TODO: add support for JNI - @Override - protected synchronized native int finalize(long stmt); - - // TODO: add support for JNI - @Override - public synchronized native int step(long stmt); + private native long connect0(byte[] path, long databasePtr) throws SQLException; @VisibleForTesting native void throwJavaException(int errorCode) throws SQLException; @@ -120,42 +115,6 @@ public final class LimboDB extends AbstractDB { */ @NativeInvocation private void throwLimboException(int errorCode, byte[] errorMessageBytes) throws SQLException { - String errorMessage = utf8ByteBufferToString(errorMessageBytes); - throw buildLimboException(errorCode, errorMessage); - } - - /** - * Throws formatted SQLException with error code and message. - * - * @param errorCode Error code. - * @param errorMessage Error message. - */ - public LimboException buildLimboException(int errorCode, @Nullable String errorMessage) throws SQLException { - LimboErrorCode code = LimboErrorCode.getErrorCode(errorCode); - String msg; - if (code == LimboErrorCode.UNKNOWN_ERROR) { - msg = String.format("%s:%s (%s)", code, errorCode, errorMessage); - } else { - msg = String.format("%s (%s)", code, errorMessage); - } - - return new LimboException(msg, code); - } - - @Nullable - private static String utf8ByteBufferToString(@Nullable byte[] buffer) { - if (buffer == null) { - return null; - } - - return new String(buffer, StandardCharsets.UTF_8); - } - - @Nullable - private static byte[] stringToUtf8ByteArray(@Nullable String str) { - if (str == null) { - return null; - } - return str.getBytes(StandardCharsets.UTF_8); + LimboExceptionUtils.throwLimboException(errorCode, errorMessageBytes); } } diff --git a/bindings/java/src/main/java/org/github/tursodatabase/core/LimboDBFactory.java b/bindings/java/src/main/java/org/github/tursodatabase/core/LimboDBFactory.java new file mode 100644 index 000000000..f7a81fd04 --- /dev/null +++ b/bindings/java/src/main/java/org/github/tursodatabase/core/LimboDBFactory.java @@ -0,0 +1,47 @@ +package org.github.tursodatabase.core; + +import java.sql.SQLException; +import java.util.Properties; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Factory class for managing and creating instances of {@link LimboDB}. + * This class ensures that multiple instances of {@link LimboDB} with the same URL are not created. + */ +public class LimboDBFactory { + + private static final ConcurrentHashMap databaseHolder = new ConcurrentHashMap<>(); + + /** + * If a database with the same URL already exists, it returns the existing instance. + * Otherwise, it creates a new instance and stores it in the database holder. + * + * @param url the URL of the database + * @param filePath the path to the database file + * @param properties additional properties for the database connection + * @return an instance of {@link LimboDB} + * @throws SQLException if there is an error opening the connection + * @throws IllegalArgumentException if the fileName is empty + */ + public static LimboDB open(String url, String filePath, Properties properties) throws SQLException { + if (databaseHolder.containsKey(url)) { + return databaseHolder.get(url); + } + + if (filePath.isEmpty()) { + throw new IllegalArgumentException("filePath should not be empty"); + } + + final LimboDB database; + try { + LimboDB.load(); + database = LimboDB.create(url, filePath); + } catch (Exception e) { + throw new SQLException("Error opening connection", e); + } + + database.open(0); + databaseHolder.put(url, database); + return database; + } +} diff --git a/bindings/java/src/main/java/org/github/tursodatabase/core/LimboResultSet.java b/bindings/java/src/main/java/org/github/tursodatabase/core/LimboResultSet.java new file mode 100644 index 000000000..5d8b495ba --- /dev/null +++ b/bindings/java/src/main/java/org/github/tursodatabase/core/LimboResultSet.java @@ -0,0 +1,42 @@ +package org.github.tursodatabase.core; + +import java.sql.SQLException; + +/** + * JDBC ResultSet. + */ +public abstract class LimboResultSet { + + protected final LimboStatement statement; + + // Whether the result set does not have any rows. + protected boolean isEmptyResultSet = false; + // If the result set is open. Doesn't mean it has results. + private boolean open = false; + // Maximum number of rows as set by the statement + protected long maxRows; + // number of current row, starts at 1 (0 is used to represent loading data) + protected int row = 0; + + protected LimboResultSet(LimboStatement statement) { + this.statement = statement; + } + + /** + * Checks the status of the result set. + * + * @return true if it's ready to iterate over the result set; false otherwise. + */ + public boolean isOpen() { + return open; + } + + /** + * @throws SQLException if not {@link #open} + */ + protected void checkOpen() throws SQLException { + if (!open) { + throw new SQLException("ResultSet closed"); + } + } +} diff --git a/bindings/java/src/main/java/org/github/tursodatabase/core/LimboStatement.java b/bindings/java/src/main/java/org/github/tursodatabase/core/LimboStatement.java new file mode 100644 index 000000000..592593df4 --- /dev/null +++ b/bindings/java/src/main/java/org/github/tursodatabase/core/LimboStatement.java @@ -0,0 +1,68 @@ +package org.github.tursodatabase.core; + +import org.github.tursodatabase.annotations.NativeInvocation; +import org.github.tursodatabase.annotations.Nullable; +import org.github.tursodatabase.jdbc4.JDBC4ResultSet; +import org.github.tursodatabase.utils.LimboExceptionUtils; + +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; + +public abstract class LimboStatement { + + protected final LimboConnection connection; + protected final LimboResultSet resultSet; + + @Nullable + protected String sql = null; + + protected LimboStatement(LimboConnection connection) { + this.connection = connection; + this.resultSet = new JDBC4ResultSet(this); + } + + protected void internalClose() throws SQLException { + // TODO + } + + protected void clearGeneratedKeys() throws SQLException { + // TODO + } + + protected void updateGeneratedKeys() throws SQLException { + // TODO + } + + // TODO: associate the result with CoreResultSet + // TODO: we can make this async!! + // TODO: distinguish queries that return result or doesn't return result + protected List execute(long stmtPointer) throws SQLException { + List result = new ArrayList<>(); + while (true) { + Object[] stepResult = step(stmtPointer); + if (stepResult != null) { + for (int i = 0; i < stepResult.length; i++) { + System.out.println("stepResult" + i + ": " + stepResult[i]); + } + } + if (stepResult == null) break; + result.add(stepResult); + } + + return result; + } + + private native Object[] step(long stmtPointer) throws SQLException; + + /** + * Throws formatted SQLException with error code and message. + * + * @param errorCode Error code. + * @param errorMessageBytes Error message. + */ + @NativeInvocation + private void throwLimboException(int errorCode, byte[] errorMessageBytes) throws SQLException { + LimboExceptionUtils.throwLimboException(errorCode, errorMessageBytes); + } +} diff --git a/bindings/java/src/main/java/org/github/tursodatabase/core/SafeStmtPtr.java b/bindings/java/src/main/java/org/github/tursodatabase/core/SafeStmtPtr.java deleted file mode 100644 index 7c6902c42..000000000 --- a/bindings/java/src/main/java/org/github/tursodatabase/core/SafeStmtPtr.java +++ /dev/null @@ -1,5 +0,0 @@ -package org.github.tursodatabase.core; - -// TODO: add fields and methods -public class SafeStmtPtr { -} diff --git a/bindings/java/src/main/java/org/github/tursodatabase/core/Codes.java b/bindings/java/src/main/java/org/github/tursodatabase/core/SqliteCode.java similarity index 98% rename from bindings/java/src/main/java/org/github/tursodatabase/core/Codes.java rename to bindings/java/src/main/java/org/github/tursodatabase/core/SqliteCode.java index 0f8a3c402..3a879cb46 100644 --- a/bindings/java/src/main/java/org/github/tursodatabase/core/Codes.java +++ b/bindings/java/src/main/java/org/github/tursodatabase/core/SqliteCode.java @@ -15,7 +15,10 @@ */ package org.github.tursodatabase.core; -public class Codes { +/** + * Sqlite error codes. + */ +public class SqliteCode { /** Successful result */ public static final int SQLITE_OK = 0; diff --git a/bindings/java/src/main/java/org/github/tursodatabase/exceptions/ErrorCode.java b/bindings/java/src/main/java/org/github/tursodatabase/exceptions/ErrorCode.java deleted file mode 100644 index 7dbed9812..000000000 --- a/bindings/java/src/main/java/org/github/tursodatabase/exceptions/ErrorCode.java +++ /dev/null @@ -1,13 +0,0 @@ -package org.github.tursodatabase.exceptions; - - -/** - * This class defines error codes that correspond to specific error conditions - * that may occur while communicating with the JNI. - *

- * Refer to ErrorCode in rust package. - * TODO: Deprecate - */ -public class ErrorCode { - public static int CONNECTION_FAILURE = -1; -} diff --git a/bindings/java/src/main/java/org/github/tursodatabase/jdbc4/JDBC4Connection.java b/bindings/java/src/main/java/org/github/tursodatabase/jdbc4/JDBC4Connection.java index 5883f7487..dc404a9cb 100644 --- a/bindings/java/src/main/java/org/github/tursodatabase/jdbc4/JDBC4Connection.java +++ b/bindings/java/src/main/java/org/github/tursodatabase/jdbc4/JDBC4Connection.java @@ -1,6 +1,6 @@ package org.github.tursodatabase.jdbc4; -import org.github.tursodatabase.LimboConnection; +import org.github.tursodatabase.core.LimboConnection; import org.github.tursodatabase.annotations.SkipNullableCheck; import java.sql.*; @@ -11,8 +11,12 @@ import java.util.concurrent.Executor; public class JDBC4Connection extends LimboConnection { - public JDBC4Connection(String url, String fileName, Properties properties) throws SQLException { - super(url, fileName, properties); + public JDBC4Connection(String url, String filePath) throws SQLException { + super(url, filePath); + } + + public JDBC4Connection(String url, String filePath, Properties properties) throws SQLException { + super(url, filePath, properties); } @Override diff --git a/bindings/java/src/main/java/org/github/tursodatabase/jdbc4/JDBC4ResultSet.java b/bindings/java/src/main/java/org/github/tursodatabase/jdbc4/JDBC4ResultSet.java new file mode 100644 index 000000000..07bf6ed92 --- /dev/null +++ b/bindings/java/src/main/java/org/github/tursodatabase/jdbc4/JDBC4ResultSet.java @@ -0,0 +1,1126 @@ +package org.github.tursodatabase.jdbc4; + +import org.github.tursodatabase.annotations.SkipNullableCheck; +import org.github.tursodatabase.core.LimboResultSet; +import org.github.tursodatabase.core.LimboStatement; + +import java.io.InputStream; +import java.io.Reader; +import java.math.BigDecimal; +import java.net.URL; +import java.sql.*; +import java.util.Calendar; +import java.util.Map; + +public class JDBC4ResultSet extends LimboResultSet implements ResultSet { + + public JDBC4ResultSet(LimboStatement statement) { + super(statement); + } + + @Override + public boolean next() throws SQLException { + // TODO + return false; + } + + @Override + public void close() throws SQLException { + // TODO + } + + @Override + public boolean wasNull() throws SQLException { + // TODO + return false; + } + + @Override + public String getString(int columnIndex) throws SQLException { + // TODO + return ""; + } + + @Override + public boolean getBoolean(int columnIndex) throws SQLException { + // TODO + return false; + } + + @Override + public byte getByte(int columnIndex) throws SQLException { + // TODO + return 0; + } + + @Override + public short getShort(int columnIndex) throws SQLException { + // TODO + return 0; + } + + @Override + public int getInt(int columnIndex) throws SQLException { + // TODO + return 0; + } + + @Override + public long getLong(int columnIndex) throws SQLException { + // TODO + return 0; + } + + @Override + public float getFloat(int columnIndex) throws SQLException { + // TODO + return 0; + } + + @Override + public double getDouble(int columnIndex) throws SQLException { + // TODO + return 0; + } + + @Override + @SkipNullableCheck + public BigDecimal getBigDecimal(int columnIndex, int scale) throws SQLException { + // TODO + return null; + } + + @Override + public byte[] getBytes(int columnIndex) throws SQLException { + // TODO + return new byte[0]; + } + + @Override + @SkipNullableCheck + public Date getDate(int columnIndex) throws SQLException { + // TODO + return null; + } + + @Override + @SkipNullableCheck + public Time getTime(int columnIndex) throws SQLException { + // TODO + return null; + } + + @Override + @SkipNullableCheck + public Timestamp getTimestamp(int columnIndex) throws SQLException { + // TODO + return null; + } + + @Override + @SkipNullableCheck + public InputStream getAsciiStream(int columnIndex) throws SQLException { + // TODO + return null; + } + + @Override + @SkipNullableCheck + public InputStream getUnicodeStream(int columnIndex) throws SQLException { + // TODO + return null; + } + + @Override + @SkipNullableCheck + public InputStream getBinaryStream(int columnIndex) throws SQLException { + // TODO + return null; + } + + @Override + public String getString(String columnLabel) throws SQLException { + // TODO + return ""; + } + + @Override + public boolean getBoolean(String columnLabel) throws SQLException { + // TODO + return false; + } + + @Override + public byte getByte(String columnLabel) throws SQLException { + // TODO + return 0; + } + + @Override + public short getShort(String columnLabel) throws SQLException { + // TODO + return 0; + } + + @Override + public int getInt(String columnLabel) throws SQLException { + // TODO + return 0; + } + + @Override + public long getLong(String columnLabel) throws SQLException { + // TODO + return 0; + } + + @Override + public float getFloat(String columnLabel) throws SQLException { + // TODO + return 0; + } + + @Override + public double getDouble(String columnLabel) throws SQLException { + // TODO + return 0; + } + + @Override + @SkipNullableCheck + public BigDecimal getBigDecimal(String columnLabel, int scale) throws SQLException { + // TODO + return null; + } + + @Override + public byte[] getBytes(String columnLabel) throws SQLException { + // TODO + return new byte[0]; + } + + @Override + @SkipNullableCheck + public Date getDate(String columnLabel) throws SQLException { + // TODO + return null; + } + + @Override + @SkipNullableCheck + public Time getTime(String columnLabel) throws SQLException { + // TODO + return null; + } + + @Override + @SkipNullableCheck + public Timestamp getTimestamp(String columnLabel) throws SQLException { + // TODO + return null; + } + + @Override + @SkipNullableCheck + public InputStream getAsciiStream(String columnLabel) throws SQLException { + // TODO + return null; + } + + @Override + @SkipNullableCheck + public InputStream getUnicodeStream(String columnLabel) throws SQLException { + // TODO + return null; + } + + @Override + @SkipNullableCheck + public InputStream getBinaryStream(String columnLabel) throws SQLException { + // TODO + return null; + } + + @Override + @SkipNullableCheck + public SQLWarning getWarnings() throws SQLException { + // TODO + return null; + } + + @Override + public void clearWarnings() throws SQLException { + // TODO + } + + @Override + public String getCursorName() throws SQLException { + // TODO + return ""; + } + + @Override + @SkipNullableCheck + public ResultSetMetaData getMetaData() throws SQLException { + // TODO + return null; + } + + @Override + @SkipNullableCheck + public Object getObject(int columnIndex) throws SQLException { + // TODO + return null; + } + + @Override + @SkipNullableCheck + public Object getObject(String columnLabel) throws SQLException { + // TODO + return null; + } + + @Override + public int findColumn(String columnLabel) throws SQLException { + // TODO + return 0; + } + + @Override + @SkipNullableCheck + public Reader getCharacterStream(int columnIndex) throws SQLException { + // TODO + return null; + } + + @Override + @SkipNullableCheck + public Reader getCharacterStream(String columnLabel) throws SQLException { + // TODO + return null; + } + + @Override + @SkipNullableCheck + public BigDecimal getBigDecimal(int columnIndex) throws SQLException { + // TODO + return null; + } + + @Override + @SkipNullableCheck + public BigDecimal getBigDecimal(String columnLabel) throws SQLException { + // TODO + return null; + } + + @Override + public boolean isBeforeFirst() throws SQLException { + // TODO + return false; + } + + @Override + public boolean isAfterLast() throws SQLException { + // TODO + return false; + } + + @Override + public boolean isFirst() throws SQLException { + // TODO + return false; + } + + @Override + public boolean isLast() throws SQLException { + // TODO + return false; + } + + @Override + public void beforeFirst() throws SQLException { + // TODO + } + + @Override + public void afterLast() throws SQLException { + // TODO + } + + @Override + public boolean first() throws SQLException { + // TODO + return false; + } + + @Override + public boolean last() throws SQLException { + // TODO + return false; + } + + @Override + public int getRow() throws SQLException { + // TODO + return 0; + } + + @Override + public boolean absolute(int row) throws SQLException { + // TODO + return false; + } + + @Override + public boolean relative(int rows) throws SQLException { + return false; + } + + @Override + public boolean previous() throws SQLException { + // TODO + return false; + } + + @Override + public void setFetchDirection(int direction) throws SQLException { + // TODO + } + + @Override + public int getFetchDirection() throws SQLException { + // TODO + return 0; + } + + @Override + public void setFetchSize(int rows) throws SQLException { + // TODO + } + + @Override + public int getFetchSize() throws SQLException { + // TODO + return 0; + } + + @Override + public int getType() throws SQLException { + // TODO + return 0; + } + + @Override + public int getConcurrency() throws SQLException { + // TODO + return 0; + } + + @Override + public boolean rowUpdated() throws SQLException { + // TODO + return false; + } + + @Override + public boolean rowInserted() throws SQLException { + // TODO + return false; + } + + @Override + public boolean rowDeleted() throws SQLException { + // TODO + return false; + } + + @Override + public void updateNull(int columnIndex) throws SQLException { + // TODO + } + + @Override + public void updateBoolean(int columnIndex, boolean x) throws SQLException { + // TODO + } + + @Override + public void updateByte(int columnIndex, byte x) throws SQLException { + // TODO + } + + @Override + public void updateShort(int columnIndex, short x) throws SQLException { + // TODO + } + + @Override + public void updateInt(int columnIndex, int x) throws SQLException { + // TODO + } + + @Override + public void updateLong(int columnIndex, long x) throws SQLException { + // TODO + } + + @Override + public void updateFloat(int columnIndex, float x) throws SQLException { + // TODO + } + + @Override + public void updateDouble(int columnIndex, double x) throws SQLException { + // TODO + } + + @Override + public void updateBigDecimal(int columnIndex, BigDecimal x) throws SQLException { + // TODO + } + + @Override + public void updateString(int columnIndex, String x) throws SQLException { + // TODO + } + + @Override + public void updateBytes(int columnIndex, byte[] x) throws SQLException { + // TODO + } + + @Override + public void updateDate(int columnIndex, Date x) throws SQLException { + // TODO + } + + @Override + public void updateTime(int columnIndex, Time x) throws SQLException { + // TODO + } + + @Override + public void updateTimestamp(int columnIndex, Timestamp x) throws SQLException { + // TODO + } + + @Override + public void updateAsciiStream(int columnIndex, InputStream x, int length) throws SQLException { + // TODO + } + + @Override + public void updateBinaryStream(int columnIndex, InputStream x, int length) throws SQLException { + // TODO + } + + @Override + public void updateCharacterStream(int columnIndex, Reader x, int length) throws SQLException { + // TODO + } + + @Override + public void updateObject(int columnIndex, Object x, int scaleOrLength) throws SQLException { + // TODO + } + + @Override + public void updateObject(int columnIndex, Object x) throws SQLException { + // TODO + } + + @Override + public void updateNull(String columnLabel) throws SQLException { + // TODO + } + + @Override + public void updateBoolean(String columnLabel, boolean x) throws SQLException { + // TODO + } + + @Override + public void updateByte(String columnLabel, byte x) throws SQLException { + // TODO + } + + @Override + public void updateShort(String columnLabel, short x) throws SQLException { + // TODO + } + + @Override + public void updateInt(String columnLabel, int x) throws SQLException { + // TODO + } + + @Override + public void updateLong(String columnLabel, long x) throws SQLException { + // TODO + } + + @Override + public void updateFloat(String columnLabel, float x) throws SQLException { + // TODO + } + + @Override + public void updateDouble(String columnLabel, double x) throws SQLException { + // TODO + } + + @Override + public void updateBigDecimal(String columnLabel, BigDecimal x) throws SQLException { + // TODO + } + + @Override + public void updateString(String columnLabel, String x) throws SQLException { + // TODO + } + + @Override + public void updateBytes(String columnLabel, byte[] x) throws SQLException { + // TODO + } + + @Override + public void updateDate(String columnLabel, Date x) throws SQLException { + // TODO + } + + @Override + public void updateTime(String columnLabel, Time x) throws SQLException { + // TODO + } + + @Override + public void updateTimestamp(String columnLabel, Timestamp x) throws SQLException { + // TODO + } + + @Override + public void updateAsciiStream(String columnLabel, InputStream x, int length) throws SQLException { + // TODO + } + + @Override + public void updateBinaryStream(String columnLabel, InputStream x, int length) throws SQLException { + // TODO + } + + @Override + public void updateCharacterStream(String columnLabel, Reader reader, int length) throws SQLException { + // TODO + } + + @Override + public void updateObject(String columnLabel, Object x, int scaleOrLength) throws SQLException { + // TODO + } + + @Override + public void updateObject(String columnLabel, Object x) throws SQLException { + // TODO + } + + @Override + public void insertRow() throws SQLException { + // TODO + } + + @Override + public void updateRow() throws SQLException { + // TODO + } + + @Override + public void deleteRow() throws SQLException { + // TODO + } + + @Override + public void refreshRow() throws SQLException { + // TODO + } + + @Override + public void cancelRowUpdates() throws SQLException { + // TODO + } + + @Override + public void moveToInsertRow() throws SQLException { + // TODO + } + + @Override + public void moveToCurrentRow() throws SQLException { + // TODO + } + + @Override + @SkipNullableCheck + public Statement getStatement() throws SQLException { + // TODO + return null; + } + + @Override + @SkipNullableCheck + public Object getObject(int columnIndex, Map> map) throws SQLException { + // TODO + return null; + } + + @Override + @SkipNullableCheck + public Ref getRef(int columnIndex) throws SQLException { + // TODO + return null; + } + + @Override + @SkipNullableCheck + public Blob getBlob(int columnIndex) throws SQLException { + // TODO + return null; + } + + @Override + @SkipNullableCheck + public Clob getClob(int columnIndex) throws SQLException { + // TODO + return null; + } + + @Override + @SkipNullableCheck + public Array getArray(int columnIndex) throws SQLException { + // TODO + return null; + } + + @Override + @SkipNullableCheck + public Object getObject(String columnLabel, Map> map) throws SQLException { + // TODO + return null; + } + + @Override + @SkipNullableCheck + public Ref getRef(String columnLabel) throws SQLException { + // TODO + return null; + } + + @Override + @SkipNullableCheck + public Blob getBlob(String columnLabel) throws SQLException { + // TODO + return null; + } + + @Override + @SkipNullableCheck + public Clob getClob(String columnLabel) throws SQLException { + // TODO + return null; + } + + @Override + @SkipNullableCheck + public Array getArray(String columnLabel) throws SQLException { + // TODO + return null; + } + + @Override + @SkipNullableCheck + public Date getDate(int columnIndex, Calendar cal) throws SQLException { + // TODO + return null; + } + + @Override + @SkipNullableCheck + public Date getDate(String columnLabel, Calendar cal) throws SQLException { + // TODO + return null; + } + + @Override + @SkipNullableCheck + public Time getTime(int columnIndex, Calendar cal) throws SQLException { + // TODO + return null; + } + + @Override + @SkipNullableCheck + public Time getTime(String columnLabel, Calendar cal) throws SQLException { + // TODO + return null; + } + + @Override + @SkipNullableCheck + public Timestamp getTimestamp(int columnIndex, Calendar cal) throws SQLException { + // TODO + return null; + } + + @Override + @SkipNullableCheck + public Timestamp getTimestamp(String columnLabel, Calendar cal) throws SQLException { + // TODO + return null; + } + + @Override + @SkipNullableCheck + public URL getURL(int columnIndex) throws SQLException { + // TODO + return null; + } + + @Override + @SkipNullableCheck + public URL getURL(String columnLabel) throws SQLException { + // TODO + return null; + } + + @Override + public void updateRef(int columnIndex, Ref x) throws SQLException { + // TODO + } + + @Override + public void updateRef(String columnLabel, Ref x) throws SQLException { + // TODO + } + + @Override + public void updateBlob(int columnIndex, Blob x) throws SQLException { + // TODO + } + + @Override + public void updateBlob(String columnLabel, Blob x) throws SQLException { + // TODO + } + + @Override + public void updateClob(int columnIndex, Clob x) throws SQLException { + // TODO + } + + @Override + public void updateClob(String columnLabel, Clob x) throws SQLException { + // TODO + } + + @Override + public void updateArray(int columnIndex, Array x) throws SQLException { + // TODO + } + + @Override + public void updateArray(String columnLabel, Array x) throws SQLException { + // TODO + } + + @Override + @SkipNullableCheck + public RowId getRowId(int columnIndex) throws SQLException { + // TODO + return null; + } + + @Override + @SkipNullableCheck + public RowId getRowId(String columnLabel) throws SQLException { + // TODO + return null; + } + + @Override + public void updateRowId(int columnIndex, RowId x) throws SQLException { + // TODO + } + + @Override + public void updateRowId(String columnLabel, RowId x) throws SQLException { + // TODO + } + + @Override + public int getHoldability() throws SQLException { + // TODO + return 0; + } + + @Override + public boolean isClosed() throws SQLException { + // TODO + return false; + } + + @Override + public void updateNString(int columnIndex, String nString) throws SQLException { + // TODO + } + + @Override + public void updateNString(String columnLabel, String nString) throws SQLException { + // TODO + } + + @Override + public void updateNClob(int columnIndex, NClob nClob) throws SQLException { + // TODO + } + + @Override + public void updateNClob(String columnLabel, NClob nClob) throws SQLException { + // TODO + } + + @Override + @SkipNullableCheck + public NClob getNClob(int columnIndex) throws SQLException { + // TODO + return null; + } + + @Override + @SkipNullableCheck + public NClob getNClob(String columnLabel) throws SQLException { + // TODO + return null; + } + + @Override + @SkipNullableCheck + public SQLXML getSQLXML(int columnIndex) throws SQLException { + // TODO + return null; + } + + @Override + @SkipNullableCheck + public SQLXML getSQLXML(String columnLabel) throws SQLException { + // TODO + return null; + } + + @Override + public void updateSQLXML(int columnIndex, SQLXML xmlObject) throws SQLException { + // TODO + } + + @Override + public void updateSQLXML(String columnLabel, SQLXML xmlObject) throws SQLException { + // TODO + } + + @Override + public String getNString(int columnIndex) throws SQLException { + // TODO + return ""; + } + + @Override + public String getNString(String columnLabel) throws SQLException { + // TODO + return ""; + } + + @Override + @SkipNullableCheck + public Reader getNCharacterStream(int columnIndex) throws SQLException { + // TODO + return null; + } + + @Override + @SkipNullableCheck + public Reader getNCharacterStream(String columnLabel) throws SQLException { + // TODO + return null; + } + + @Override + public void updateNCharacterStream(int columnIndex, Reader x, long length) throws SQLException { + // TODO + } + + @Override + public void updateNCharacterStream(String columnLabel, Reader reader, long length) throws SQLException { + // TODO + } + + @Override + public void updateAsciiStream(int columnIndex, InputStream x, long length) throws SQLException { + // TODO + } + + @Override + public void updateBinaryStream(int columnIndex, InputStream x, long length) throws SQLException { + // TODO + } + + @Override + public void updateCharacterStream(int columnIndex, Reader x, long length) throws SQLException { + // TODO + } + + @Override + public void updateAsciiStream(String columnLabel, InputStream x, long length) throws SQLException { + // TODO + } + + @Override + public void updateBinaryStream(String columnLabel, InputStream x, long length) throws SQLException { + // TODO + } + + @Override + public void updateCharacterStream(String columnLabel, Reader reader, long length) throws SQLException { + // TODO + } + + @Override + public void updateBlob(int columnIndex, InputStream inputStream, long length) throws SQLException { + // TODO + } + + @Override + public void updateBlob(String columnLabel, InputStream inputStream, long length) throws SQLException { + // TODO + } + + @Override + public void updateClob(int columnIndex, Reader reader, long length) throws SQLException { + // TODO + } + + @Override + public void updateClob(String columnLabel, Reader reader, long length) throws SQLException { + // TODO + } + + @Override + public void updateNClob(int columnIndex, Reader reader, long length) throws SQLException { + // TODO + } + + @Override + public void updateNClob(String columnLabel, Reader reader, long length) throws SQLException { + // TODO + } + + @Override + public void updateNCharacterStream(int columnIndex, Reader x) throws SQLException { + // TODO + } + + @Override + public void updateNCharacterStream(String columnLabel, Reader reader) throws SQLException { + // TODO + } + + @Override + public void updateAsciiStream(int columnIndex, InputStream x) throws SQLException { + // TODO + } + + @Override + public void updateBinaryStream(int columnIndex, InputStream x) throws SQLException { + // TODO + } + + @Override + public void updateCharacterStream(int columnIndex, Reader x) throws SQLException { + // TODO + } + + @Override + public void updateAsciiStream(String columnLabel, InputStream x) throws SQLException { + // TODO + } + + @Override + public void updateBinaryStream(String columnLabel, InputStream x) throws SQLException { + // TODO + } + + @Override + public void updateCharacterStream(String columnLabel, Reader reader) throws SQLException { + // TODO + } + + @Override + public void updateBlob(int columnIndex, InputStream inputStream) throws SQLException { + // TODO + } + + @Override + public void updateBlob(String columnLabel, InputStream inputStream) throws SQLException { + // TODO + } + + @Override + public void updateClob(int columnIndex, Reader reader) throws SQLException { + // TODO + } + + @Override + public void updateClob(String columnLabel, Reader reader) throws SQLException { + // TODO + } + + @Override + public void updateNClob(int columnIndex, Reader reader) throws SQLException { + // TODO + } + + @Override + public void updateNClob(String columnLabel, Reader reader) throws SQLException { + // TODO + } + + @Override + @SkipNullableCheck + public T getObject(int columnIndex, Class type) throws SQLException { + // TODO + return null; + } + + @Override + @SkipNullableCheck + public T getObject(String columnLabel, Class type) throws SQLException { + // TODO + return null; + } + + @Override + @SkipNullableCheck + public T unwrap(Class iface) throws SQLException { + // TODO + return null; + } + + @Override + public boolean isWrapperFor(Class iface) throws SQLException { + // TODO + return false; + } + + private SQLException throwNotSupportedException() { + return new SQLFeatureNotSupportedException("Not implemented by the driver"); + } +} diff --git a/bindings/java/src/main/java/org/github/tursodatabase/jdbc4/JDBC4Statement.java b/bindings/java/src/main/java/org/github/tursodatabase/jdbc4/JDBC4Statement.java index f1fb14221..4a06d20a1 100644 --- a/bindings/java/src/main/java/org/github/tursodatabase/jdbc4/JDBC4Statement.java +++ b/bindings/java/src/main/java/org/github/tursodatabase/jdbc4/JDBC4Statement.java @@ -1,15 +1,18 @@ package org.github.tursodatabase.jdbc4; -import org.github.tursodatabase.LimboConnection; import org.github.tursodatabase.annotations.SkipNullableCheck; -import org.github.tursodatabase.core.CoreStatement; +import org.github.tursodatabase.core.LimboConnection; +import org.github.tursodatabase.core.LimboStatement; import java.sql.*; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.locks.ReentrantLock; /** * Implementation of the {@link Statement} interface for JDBC 4. */ -public class JDBC4Statement extends CoreStatement implements Statement { +public class JDBC4Statement extends LimboStatement implements Statement { private boolean closed; private boolean closeOnCompletion; @@ -18,6 +21,12 @@ public class JDBC4Statement extends CoreStatement implements Statement { private final int resultSetConcurrency; private final int resultSetHoldability; + private int queryTimeoutSeconds; + private long updateCount; + private boolean exhaustedResults = false; + + private ReentrantLock connectionLock = new ReentrantLock(); + public JDBC4Statement(LimboConnection connection) { this(connection, ResultSet.TYPE_FORWARD_ONLY, ResultSet.CONCUR_READ_ONLY, ResultSet.CLOSE_CURSORS_AT_COMMIT); } @@ -84,7 +93,10 @@ public class JDBC4Statement extends CoreStatement implements Statement { @Override public void setQueryTimeout(int seconds) throws SQLException { - // TODO + if (seconds < 0) { + throw new SQLException("Query timeout must be greater than 0"); + } + this.queryTimeoutSeconds = seconds; } @Override @@ -111,8 +123,22 @@ public class JDBC4Statement extends CoreStatement implements Statement { @Override public boolean execute(String sql) throws SQLException { - // TODO - return false; + internalClose(); + + return this.withConnectionTimeout( + () -> { + try { + connectionLock.lock(); + final long stmtPointer = connection.prepare(sql); + List result = execute(stmtPointer); + updateGeneratedKeys(); + exhaustedResults = false; + return !result.isEmpty(); + } finally { + connectionLock.unlock(); + } + } + ); } @Override @@ -287,4 +313,25 @@ public class JDBC4Statement extends CoreStatement implements Statement { // TODO return false; } + + private T withConnectionTimeout(SQLCallable callable) throws SQLException { + final int originalBusyTimeoutMillis = connection.getBusyTimeout(); + if (queryTimeoutSeconds > 0) { + // TODO: set busy timeout + connection.setBusyTimeout(1000 * queryTimeoutSeconds); + } + + try { + return callable.call(); + } finally { + if (queryTimeoutSeconds > 0) { + connection.setBusyTimeout(originalBusyTimeoutMillis); + } + } + } + + @FunctionalInterface + protected interface SQLCallable { + T call() throws SQLException; + } } diff --git a/bindings/java/src/main/java/org/github/tursodatabase/utils/ByteArrayUtils.java b/bindings/java/src/main/java/org/github/tursodatabase/utils/ByteArrayUtils.java new file mode 100644 index 000000000..a89f05042 --- /dev/null +++ b/bindings/java/src/main/java/org/github/tursodatabase/utils/ByteArrayUtils.java @@ -0,0 +1,24 @@ +package org.github.tursodatabase.utils; + +import org.github.tursodatabase.annotations.Nullable; + +import java.nio.charset.StandardCharsets; + +public class ByteArrayUtils { + @Nullable + public static String utf8ByteBufferToString(@Nullable byte[] buffer) { + if (buffer == null) { + return null; + } + + return new String(buffer, StandardCharsets.UTF_8); + } + + @Nullable + public static byte[] stringToUtf8ByteArray(@Nullable String str) { + if (str == null) { + return null; + } + return str.getBytes(StandardCharsets.UTF_8); + } +} diff --git a/bindings/java/src/main/java/org/github/tursodatabase/utils/LimboExceptionUtils.java b/bindings/java/src/main/java/org/github/tursodatabase/utils/LimboExceptionUtils.java new file mode 100644 index 000000000..9a45db040 --- /dev/null +++ b/bindings/java/src/main/java/org/github/tursodatabase/utils/LimboExceptionUtils.java @@ -0,0 +1,40 @@ +package org.github.tursodatabase.utils; + +import org.github.tursodatabase.LimboErrorCode; +import org.github.tursodatabase.annotations.Nullable; +import org.github.tursodatabase.exceptions.LimboException; + +import java.sql.SQLException; + +import static org.github.tursodatabase.utils.ByteArrayUtils.utf8ByteBufferToString; + +public class LimboExceptionUtils { + /** + * Throws formatted SQLException with error code and message. + * + * @param errorCode Error code. + * @param errorMessageBytes Error message. + */ + public static void throwLimboException(int errorCode, byte[] errorMessageBytes) throws SQLException { + String errorMessage = utf8ByteBufferToString(errorMessageBytes); + throw buildLimboException(errorCode, errorMessage); + } + + /** + * Throws formatted SQLException with error code and message. + * + * @param errorCode Error code. + * @param errorMessage Error message. + */ + public static LimboException buildLimboException(int errorCode, @Nullable String errorMessage) throws SQLException { + LimboErrorCode code = LimboErrorCode.getErrorCode(errorCode); + String msg; + if (code == LimboErrorCode.UNKNOWN_ERROR) { + msg = String.format("%s:%s (%s)", code, errorCode, errorMessage); + } else { + msg = String.format("%s (%s)", code, errorMessage); + } + + return new LimboException(msg, code); + } +} diff --git a/bindings/java/src/main/resources/logback.xml b/bindings/java/src/main/resources/logback.xml new file mode 100644 index 000000000..5143dd837 --- /dev/null +++ b/bindings/java/src/main/resources/logback.xml @@ -0,0 +1,11 @@ + + + + %d{yyyy-MM-dd HH:mm:ss} %-5level %logger{36} - %msg%n + + + + + + + diff --git a/bindings/java/src/test/java/org/github/tursodatabase/IntegrationTest.java b/bindings/java/src/test/java/org/github/tursodatabase/IntegrationTest.java new file mode 100644 index 000000000..873c41476 --- /dev/null +++ b/bindings/java/src/test/java/org/github/tursodatabase/IntegrationTest.java @@ -0,0 +1,38 @@ +package org.github.tursodatabase; + +import org.github.tursodatabase.jdbc4.JDBC4Connection; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.Properties; + +public class IntegrationTest { + + private JDBC4Connection connection; + + @BeforeEach + void setUp() throws Exception { + String filePath = TestUtils.createTempFile(); + String url = "jdbc:sqlite:" + filePath; + connection = new JDBC4Connection(url, filePath, new Properties()); + } + + @Test + @Disabled("Doesn't work on workflow. Need investigation.") + void create_table_multi_inserts_select() throws Exception { + Statement stmt = createDefaultStatement(); + stmt.execute("CREATE TABLE users (id INT PRIMARY KEY, username TEXT);"); + stmt.execute("INSERT INTO users VALUES (1, 'seonwoo');"); + stmt.execute("INSERT INTO users VALUES (2, 'seonwoo');"); + stmt.execute("INSERT INTO users VALUES (3, 'seonwoo');"); + stmt.execute("SELECT * FROM users"); + } + + private Statement createDefaultStatement() throws SQLException { + return connection.createStatement(ResultSet.TYPE_FORWARD_ONLY, ResultSet.CONCUR_READ_ONLY, ResultSet.CLOSE_CURSORS_AT_COMMIT); + } +} diff --git a/bindings/java/src/test/java/org/github/tursodatabase/JDBCTest.java b/bindings/java/src/test/java/org/github/tursodatabase/JDBCTest.java index 45452f810..7f28ddb5d 100644 --- a/bindings/java/src/test/java/org/github/tursodatabase/JDBCTest.java +++ b/bindings/java/src/test/java/org/github/tursodatabase/JDBCTest.java @@ -1,5 +1,6 @@ package org.github.tursodatabase; +import org.github.tursodatabase.core.LimboConnection; import org.junit.jupiter.api.Test; import java.sql.Connection; diff --git a/bindings/java/src/test/java/org/github/tursodatabase/core/LimboDBFactoryTest.java b/bindings/java/src/test/java/org/github/tursodatabase/core/LimboDBFactoryTest.java new file mode 100644 index 000000000..bc3150f2c --- /dev/null +++ b/bindings/java/src/test/java/org/github/tursodatabase/core/LimboDBFactoryTest.java @@ -0,0 +1,32 @@ +package org.github.tursodatabase.core; + +import org.github.tursodatabase.TestUtils; +import org.junit.jupiter.api.Test; + +import java.util.Properties; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; + +class LimboDBFactoryTest { + + @Test + void single_database_should_be_created_when_urls_are_same() throws Exception { + String filePath = TestUtils.createTempFile(); + String url = "jdbc:sqlite:" + filePath; + LimboDB db1 = LimboDBFactory.open(url, filePath, new Properties()); + LimboDB db2 = LimboDBFactory.open(url, filePath, new Properties()); + assertEquals(db1, db2); + } + + @Test + void multiple_databases_should_be_created_when_urls_differ() throws Exception { + String filePath1 = TestUtils.createTempFile(); + String filePath2 = TestUtils.createTempFile(); + String url1 = "jdbc:sqlite:" + filePath1; + String url2 = "jdbc:sqlite:" + filePath2; + LimboDB db1 = LimboDBFactory.open(url1, filePath1, new Properties()); + LimboDB db2 = LimboDBFactory.open(url2, filePath2, new Properties()); + assertNotEquals(db1, db2); + } +} diff --git a/bindings/java/src/test/java/org/github/tursodatabase/core/LimboDBTest.java b/bindings/java/src/test/java/org/github/tursodatabase/core/LimboDBTest.java index 66e842ea4..9feb39fb7 100644 --- a/bindings/java/src/test/java/org/github/tursodatabase/core/LimboDBTest.java +++ b/bindings/java/src/test/java/org/github/tursodatabase/core/LimboDBTest.java @@ -36,7 +36,7 @@ public class LimboDBTest { LimboDB.load(); LimboDB db = LimboDB.create("jdbc:sqlite:" + dbPath, dbPath); - final int limboExceptionCode = LimboErrorCode.ETC.code; + final int limboExceptionCode = LimboErrorCode.LIMBO_ETC.code; try { db.throwJavaException(limboExceptionCode); } catch (Exception e) { diff --git a/bindings/java/src/test/java/org/github/tursodatabase/jdbc4/JDBC4ConnectionTest.java b/bindings/java/src/test/java/org/github/tursodatabase/jdbc4/JDBC4ConnectionTest.java index bf2a20b88..c1b9afe56 100644 --- a/bindings/java/src/test/java/org/github/tursodatabase/jdbc4/JDBC4ConnectionTest.java +++ b/bindings/java/src/test/java/org/github/tursodatabase/jdbc4/JDBC4ConnectionTest.java @@ -1,6 +1,7 @@ package org.github.tursodatabase.jdbc4; import org.github.tursodatabase.TestUtils; +import org.github.tursodatabase.core.LimboConnection; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -10,7 +11,6 @@ import java.sql.Statement; import java.util.Properties; import static org.junit.jupiter.api.Assertions.*; -import static org.junit.jupiter.api.Assertions.assertThrows; class JDBC4ConnectionTest { @@ -18,9 +18,9 @@ class JDBC4ConnectionTest { @BeforeEach void setUp() throws Exception { - String fileUrl = TestUtils.createTempFile(); - String url = "jdbc:sqlite:" + fileUrl; - connection = new JDBC4Connection(url, fileUrl, new Properties()); + String filePath = TestUtils.createTempFile(); + String url = "jdbc:sqlite:" + filePath; + connection = new JDBC4Connection(url, filePath, new Properties()); } @Test @@ -55,4 +55,9 @@ class JDBC4ConnectionTest { connection.createStatement(ResultSet.TYPE_FORWARD_ONLY, ResultSet.CONCUR_READ_ONLY, -1); }); } + + @Test + void prepare_simple_create_table() throws Exception { + connection.prepare("CREATE TABLE users (id INT PRIMARY KEY, username TEXT)"); + } } diff --git a/licenses/bindings/java/logback-license.md b/licenses/bindings/java/logback-license.md new file mode 100644 index 000000000..1e16a14c7 --- /dev/null +++ b/licenses/bindings/java/logback-license.md @@ -0,0 +1,14 @@ +Logback LICENSE +--------------- + +Logback: the reliable, generic, fast and flexible logging framework. +Copyright (C) 1999-2024, QOS.ch. All rights reserved. + +This program and the accompanying materials are dual-licensed under +either the terms of the Eclipse Public License v1.0 as published by +the Eclipse Foundation + +or (per the licensee's choosing) + +under the terms of the GNU Lesser General Public License version 2.1 +as published by the Free Software Foundation.