Merge 'Implement open function in Java bindings' from Kim Seon Woo

## Purpose of this PR
- Implement open function
- Add basic structure for the following
  - exception handling
  - testing using gradle
## Changes
- Java
  - Remove unnecessary example code(Connection.java, Cursor.java,
Limbo.java)
  - Implement `open`
  - Add exception handling logic
  - Add junit test
- Rust
  - Add limbo_db.rs which implements native functions defined in
`Limbo.java`
  - Remove unnecessary example code in lib.rs
## TODOS
- Implement core features for AbstractDB.java and LimboDB.java (I'm
currently referencing sqlite-java, but there are some minor differences
as we use rust instead of C)
## Reference
- https://github.com/tursodatabase/limbo/issues/615

Closes #632
This commit is contained in:
Pekka Enberg
2025-01-09 18:05:42 +02:00
16 changed files with 309 additions and 315 deletions

View File

@@ -1,7 +1,7 @@
java_run: lib
export LIMBO_SYSTEM_PATH=../../target/debug && ./gradlew run
.PHONY: lib
lib:
cargo build
run_test: build_test
./gradlew test
build_test:
CARGO_TARGET_DIR=src/test/resources/limbo cargo build

View File

@@ -13,6 +13,7 @@ repositories {
dependencies {
testImplementation(platform("org.junit:junit-bom:5.10.0"))
testImplementation("org.junit.jupiter:junit-jupiter")
testImplementation("org.assertj:assertj-core:3.27.0")
}
application {
@@ -28,4 +29,6 @@ application {
tasks.test {
useJUnitPlatform()
// In order to find rust built file under resources, we need to set it as system path
systemProperty("java.library.path", "${System.getProperty("java.library.path")}:$projectDir/src/test/resources/limbo/debug")
}

View File

@@ -6,7 +6,7 @@ pub struct CustomError {
}
/// This struct defines error codes that correspond to the constants defined in the
/// Java package `org.github.tursodatabase.exceptions.ErrorCode`.
/// 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.
@@ -14,8 +14,7 @@ pub struct CustomError {
pub struct ErrorCode;
impl ErrorCode {
pub const CONNECTION_FAILURE: i32 = -1;
// TODO: change CONNECTION_FAILURE_STATEMENT_IS_DML to appropriate error code number
pub const STATEMENT_IS_DML: i32 = -1;
}

View File

@@ -1,66 +1,6 @@
mod connection;
mod cursor;
mod errors;
mod limbo_db;
mod macros;
mod utils;
use crate::connection::Connection;
use crate::errors::ErrorCode;
use jni::errors::JniError;
use jni::objects::{JClass, JString};
use jni::sys::jlong;
use jni::JNIEnv;
use std::sync::{Arc, Mutex};
/// Establishes a connection to the database specified by the given path.
///
/// This function is called from the Java side to create a connection to the database.
/// It returns a pointer to the `Connection` object, which can be used in subsequent
/// native function calls.
///
/// # Arguments
///
/// * `env` - The JNI environment pointer.
/// * `_class` - The Java class calling this function.
/// * `path` - A `JString` representing the path to the database file.
///
/// # Returns
///
/// A `jlong` representing the pointer to the newly created `Connection` object,
/// or [ErrorCode::CONNECTION_FAILURE] if the connection could not be established.
#[no_mangle]
pub extern "system" fn Java_org_github_tursodatabase_limbo_Limbo_connect<'local>(
mut env: JNIEnv<'local>,
_class: JClass<'local>,
path: JString<'local>,
) -> jlong {
connect_internal(&mut env, path).unwrap_or_else(|_| ErrorCode::CONNECTION_FAILURE as jlong)
}
#[allow(improper_ctypes_definitions, clippy::arc_with_non_send_sync)] // TODO: remove
fn connect_internal<'local>(
env: &mut JNIEnv<'local>,
path: JString<'local>,
) -> Result<jlong, JniError> {
let io = Arc::new(limbo_core::PlatformIO::new().map_err(|e| {
println!("IO initialization failed: {:?}", e);
JniError::Unknown
})?);
let path: String = env
.get_string(&path)
.expect("Failed to convert JString to Rust String")
.into();
let db = limbo_core::Database::open_file(io.clone(), &path).map_err(|e| {
println!("Failed to open database: {:?}", e);
JniError::Unknown
})?;
let conn = db.connect().clone();
let connection = Connection {
conn: Arc::new(Mutex::new(conn)),
io,
};
Ok(Box::into_raw(Box::new(connection)) as jlong)
}

View File

@@ -0,0 +1,86 @@
use jni::objects::{JByteArray, JObject};
use jni::sys::{jint, jlong};
use jni::JNIEnv;
use limbo_core::Database;
use std::sync::Arc;
const ERROR_CODE_ETC: i32 = 9999;
#[no_mangle]
#[allow(clippy::arc_with_non_send_sync)]
pub extern "system" fn Java_org_github_tursodatabase_core_LimboDB__1open_1utf8<'local>(
mut env: JNIEnv<'local>,
obj: JObject<'local>,
file_name_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());
return -1;
}
};
let path = match env
.convert_byte_array(file_name_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());
return -1;
}
},
Err(e) => {
set_err_msg_and_throw_exception(&mut env, obj, ERROR_CODE_ETC, e.to_string());
return -1;
}
};
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());
return -1;
}
};
Box::into_raw(Box::new(db)) as jlong
}
fn set_err_msg_and_throw_exception<'local>(
env: &mut JNIEnv<'local>,
obj: JObject<'local>,
err_code: i32,
err_msg: String,
) {
let error_message_pointer = Box::into_raw(Box::new(err_msg)) as i64;
match env.call_method(
obj,
"newSQLException",
"(IJ)Lorg/github/tursodatabase/exceptions/LimboException;",
&[err_code.into(), error_message_pointer.into()],
) {
Ok(_) => {
// do nothing because above method will always return Err
}
Err(_e) => {
// do nothing because our java app will handle Err
}
}
}
#[no_mangle]
pub unsafe extern "system" fn Java_org_github_tursodatabase_core_LimboDB_getErrorMessageUtf8<
'local,
>(
env: JNIEnv<'local>,
_obj: JObject<'local>,
error_message_ptr: jlong,
) -> JByteArray<'local> {
let error_message = Box::from_raw(error_message_ptr as *mut String);
let error_message_bytes = error_message.as_bytes();
env.byte_array_from_slice(error_message_bytes).unwrap()
}

View File

@@ -0,0 +1,34 @@
package org.github.tursodatabase;
public enum LimboErrorCode {
UNKNOWN_ERROR(-1, "Unknown error"),
ETC(9999, "Unclassified error");
public final int code;
public final String message;
/**
* @param code Error code
* @param message Message for the error.
*/
LimboErrorCode(int code, String message) {
this.code = code;
this.message = message;
}
public static LimboErrorCode getErrorCode(int errorCode) {
for (LimboErrorCode limboErrorCode: LimboErrorCode.values()) {
if (errorCode == limboErrorCode.code) return limboErrorCode;
}
return UNKNOWN_ERROR;
}
@Override
public String toString() {
return "LimboErrorCode{" +
"code=" + code +
", message='" + message + '\'' +
'}';
}
}

View File

@@ -1,19 +0,0 @@
package org.github.tursodatabase;
import org.github.tursodatabase.limbo.Connection;
import org.github.tursodatabase.limbo.Cursor;
import org.github.tursodatabase.limbo.Limbo;
/**
* TODO: Remove Main class. We can use test code to verify behaviors.
*/
public class Main {
public static void main(String[] args) throws Exception {
Limbo limbo = Limbo.create();
Connection connection = limbo.getConnection("database.db");
Cursor cursor = connection.cursor();
cursor.execute("SELECT * FROM example_table;");
System.out.println("result: " + cursor.fetchOne());
}
}

View File

@@ -0,0 +1,15 @@
package org.github.tursodatabase;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Annotation to mark methods that are called by native functions.
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface NativeInvocation {
}

View File

@@ -1,5 +1,9 @@
package org.github.tursodatabase.core;
import org.github.tursodatabase.LimboErrorCode;
import org.github.tursodatabase.NativeInvocation;
import org.github.tursodatabase.exceptions.LimboException;
import java.sql.SQLException;
import java.sql.SQLFeatureNotSupportedException;
import java.util.concurrent.atomic.AtomicBoolean;
@@ -10,18 +14,14 @@ import java.util.concurrent.atomic.AtomicBoolean;
* are not only to provide functionality, but to handle contractual
* differences between the JDBC specification and the Limbo API.
*/
public abstract class DB {
public abstract class AbstractDB {
private final String url;
private final String fileName;
private final AtomicBoolean closed = new AtomicBoolean(true);
public DB(String url, String fileName) throws SQLException {
public AbstractDB(String url, String filaName) throws SQLException {
this.url = url;
this.fileName = fileName;
}
public String getUrl() {
return url;
this.fileName = filaName;
}
public boolean isClosed() {
@@ -36,7 +36,7 @@ public abstract class DB {
/**
* Executes an SQL statement.
*
* @param sql SQL statement to be executed.
* @param sql SQL statement to be executed.
* @param autoCommit Whether to auto-commit the transaction.
* @throws SQLException if a database access error occurs.
*/
@@ -47,17 +47,16 @@ public abstract class DB {
/**
* Creates an SQLite interface to a database for the given connection.
* @see <a href="https://www.sqlite.org/c3ref/c_open_autoproxy.html">SQLite Open Flags</a>
*
* @param fileName The database.
* @param openFlags Flags for opening the database.
* @throws SQLException if a database access error occurs.
*/
public final synchronized void open(String fileName, int openFlags) throws SQLException {
// TODO: add implementation
throw new SQLFeatureNotSupportedException();
public final synchronized void open(int openFlags) throws SQLException {
_open(fileName, openFlags);
}
protected abstract void _open(String fileName, int openFlags) throws SQLException;
/**
* Closes a database connection and finalizes any remaining statements before the closing
* operation.
@@ -95,13 +94,13 @@ public abstract class DB {
/**
* Creates an SQLite interface to a database with the provided open flags.
* @see <a href="https://www.sqlite.org/c3ref/c_open_autoproxy.html">SQLite Open Flags</a>
*
* @param filename The database to open.
* @param fileName The database to open.
* @param openFlags Flags for opening the database.
* @return pointer to database instance
* @throws SQLException if a database access error occurs.
*/
protected abstract void _open(String filename, int openFlags) throws SQLException;
protected abstract long _open_utf8(byte[] fileName, int openFlags) throws SQLException;
/**
* Closes the SQLite interface to a database.
@@ -173,4 +172,35 @@ public abstract class DB {
// TODO: add implementation
throw new SQLFeatureNotSupportedException();
}
/**
* Throws SQL Exception with error code.
*
* @param errorCode Error code to be passed.
* @throws SQLException Formatted SQLException with error code
*/
@NativeInvocation
private LimboException newSQLException(int errorCode, long errorMessagePointer) throws SQLException {
throw newSQLException(errorCode, getErrorMessage(errorMessagePointer));
}
/**
* Throws formatted SQLException with error code and message.
*
* @param errorCode Error code to be passed.
* @param errorMessage throw newSQLException(errorCode);Error message to be passed.
* @return Formatted SQLException with error code and message.
*/
public static LimboException newSQLException(int errorCode, String errorMessage) {
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);
}
protected abstract String getErrorMessage(long errorMessagePointer);
}

View File

@@ -1,65 +1,67 @@
package org.github.tursodatabase.core;
import org.github.tursodatabase.LimboErrorCode;
import java.nio.charset.StandardCharsets;
import java.sql.SQLException;
import java.sql.SQLFeatureNotSupportedException;
/**
* This class provides a thin JNI layer over the SQLite3 C API.
*/
public final class LimboDB extends DB {
/**
* SQLite connection handle.
*/
private long pointer = 0;
public final class LimboDB extends AbstractDB {
// Pointer to database instance
private long dbPtr;
private boolean isOpen;
private static boolean isLoaded;
private static boolean loadSucceeded;
static {
if ("The Android Project".equals(System.getProperty("java.vm.vendor"))) {
System.loadLibrary("sqlitejdbc");
isLoaded = true;
loadSucceeded = true;
// TODO
} else {
// continue with non Android execution path
isLoaded = false;
loadSucceeded = false;
}
}
// url example: "jdbc:sqlite:{fileName}
/**
*
* @param url e.g. "jdbc:sqlite:fileName
* @param fileName e.g. path to file
*/
public static LimboDB create(String url, String fileName) throws SQLException {
return new LimboDB(url, fileName);
}
// TODO: receive config as argument
public LimboDB(String url, String fileName) throws SQLException {
private LimboDB(String url, String fileName) throws SQLException {
super(url, fileName);
}
/**
* Loads the SQLite interface backend.
*
* @return True if the SQLite JDBC driver is successfully loaded; false otherwise.
*/
public static boolean load() throws Exception {
if (isLoaded) return loadSucceeded;
public void load() {
if (isLoaded) return;
try {
System.loadLibrary("_limbo_java");
loadSucceeded = true;
} finally {
isLoaded = true;
}
return loadSucceeded;
}
// WRAPPER FUNCTIONS ////////////////////////////////////////////
@Override
protected synchronized void _open(String file, int openFlags) throws SQLException {
// TODO: add implementation
throw new SQLFeatureNotSupportedException();
}
// TODO: add support for JNI
synchronized native void _open_utf8(byte[] fileUtf8, int openFlags) throws SQLException;
@Override
protected synchronized native long _open_utf8(byte[] file, int openFlags) throws SQLException;
// TODO: add support for JNI
@Override
@@ -78,6 +80,15 @@ public final class LimboDB extends DB {
@Override
public native void interrupt();
@Override
protected void _open(String fileName, int openFlags) throws SQLException {
if (isOpen) {
throw newSQLException(LimboErrorCode.UNKNOWN_ERROR.code, "Already opened");
}
dbPtr = _open_utf8(stringToUtf8ByteArray(fileName), openFlags);
isOpen = true;
}
@Override
protected synchronized SafeStmtPtr prepare(String sql) throws SQLException {
// TODO: add implementation
@@ -91,4 +102,26 @@ public final class LimboDB extends DB {
// TODO: add support for JNI
@Override
public synchronized native int step(long stmt);
@Override
protected String getErrorMessage(long errorMessagePointer) {
return utf8ByteBufferToString(getErrorMessageUtf8(errorMessagePointer));
}
private native byte[] getErrorMessageUtf8(long errorMessagePointer);
private static String utf8ByteBufferToString(byte[] buffer) {
if (buffer == null) {
return null;
}
return new String(buffer, StandardCharsets.UTF_8);
}
private static byte[] stringToUtf8ByteArray(String str) {
if (str == null) {
return null;
}
return str.getBytes(StandardCharsets.UTF_8);
}
}

View File

@@ -0,0 +1,18 @@
package org.github.tursodatabase.exceptions;
import org.github.tursodatabase.LimboErrorCode;
import java.sql.SQLException;
public class LimboException extends SQLException {
private final LimboErrorCode resultCode;
public LimboException(String message, LimboErrorCode resultCode) {
super(message, null, resultCode.code & 0xff);
this.resultCode = resultCode;
}
public LimboErrorCode getResultCode() {
return resultCode;
}
}

View File

@@ -1,67 +0,0 @@
package org.github.tursodatabase.limbo;
import java.lang.Exception;
/**
* Represents a connection to the database.
* TODO: Deprecate classes under limbo package. We leave this source code for reference.
*/
public class Connection {
// Pointer to the connection object
private final long connectionPtr;
public Connection(long connectionPtr) {
this.connectionPtr = connectionPtr;
}
/**
* Creates a new cursor object using this connection.
*
* @return A new Cursor object.
* @throws Exception If the cursor cannot be created.
*/
public Cursor cursor() throws Exception {
long cursorId = cursor(connectionPtr);
return new Cursor(cursorId);
}
private native long cursor(long connectionPtr);
/**
* Closes the connection to the database.
*
* @throws Exception If there is an error closing the connection.
*/
public void close() throws Exception {
close(connectionPtr);
}
private native void close(long connectionPtr);
/**
* Commits the current transaction.
*
* @throws Exception If there is an error during commit.
*/
public void commit() throws Exception {
try {
commit(connectionPtr);
} catch (Exception e) {
System.out.println("caught exception: " + e);
}
}
private native void commit(long connectionPtr) throws Exception;
/**
* Rolls back the current transaction.
*
* @throws Exception If there is an error during rollback.
*/
public void rollback() throws Exception {
rollback(connectionPtr);
}
private native void rollback(long connectionPtr) throws Exception;
}

View File

@@ -1,86 +0,0 @@
package org.github.tursodatabase.limbo;
/**
* Represents a database cursor.
* TODO: Deprecate classes under limbo package. We leave this source code for reference.
*/
public class Cursor {
private long cursorPtr;
public Cursor(long cursorPtr) {
this.cursorPtr = cursorPtr;
}
// TODO: support parameters
public Cursor execute(String sql) {
var result = execute(cursorPtr, sql);
System.out.println("resut: " + result);
return this;
}
private static native int execute(long cursorPtr, String sql);
public Object fetchOne() throws Exception {
Object result = fetchOne(cursorPtr);
return processSingleResult(result);
}
private static native Object fetchOne(long cursorPtr);
public Object fetchAll() throws Exception {
Object result = fetchAll(cursorPtr);
return processArrayResult(result);
}
private static native Object fetchAll(long cursorPtr);
private Object processSingleResult(Object result) throws Exception {
if (result instanceof Object[]) {
System.out.println("The result is of type: Object[]");
for (Object element : (Object[]) result) {
printElementType(element);
}
return result;
} else {
printElementType(result);
return result;
}
}
private Object processArrayResult(Object result) throws Exception {
if (result instanceof Object[][]) {
System.out.println("The result is of type: Object[][]");
Object[][] array = (Object[][]) result;
for (Object[] row : array) {
for (Object element : row) {
printElementType(element);
}
}
return array;
} else {
throw new Exception("result should be of type Object[][]. Maybe internal logic has error.");
}
}
private void printElementType(Object element) {
if (element instanceof String) {
System.out.println("String: " + element);
} else if (element instanceof Integer) {
System.out.println("Integer: " + element);
} else if (element instanceof Double) {
System.out.println("Double: " + element);
} else if (element instanceof Boolean) {
System.out.println("Boolean: " + element);
} else if (element instanceof Long) {
System.out.println("Long: " + element);
} else if (element instanceof byte[]) {
System.out.print("byte[]: ");
for (byte b : (byte[]) element) {
System.out.print(b + " ");
}
System.out.println();
} else {
System.out.println("Unknown type: " + element);
}
}
}

View File

@@ -1,34 +0,0 @@
package org.github.tursodatabase.limbo;
import org.github.tursodatabase.exceptions.ErrorCode;
import java.lang.Exception;
/**
* TODO: Deprecate classes under limbo package. We leave this source code for reference.
*/
public class Limbo {
private static volatile boolean initialized;
private Limbo() {
if (!initialized) {
System.loadLibrary("_limbo_java");
initialized = true;
}
}
public static Limbo create() {
return new Limbo();
}
public Connection getConnection(String path) throws Exception {
long connectionId = connect(path);
if (connectionId == ErrorCode.CONNECTION_FAILURE) {
throw new Exception("Failed to initialize connection");
}
return new Connection(connectionId);
}
private static native long connect(String path);
}

View File

@@ -0,0 +1,13 @@
package org.github.tursodatabase;
import java.io.IOException;
import java.nio.file.Files;
public class TestUtils {
/**
* Create temporary file and returns the path.
*/
public static String createTempFile() throws IOException {
return Files.createTempFile("limbo_test_db", null).toAbsolutePath().toString();
}
}

View File

@@ -0,0 +1,29 @@
package org.github.tursodatabase.core;
import org.github.tursodatabase.TestUtils;
import org.junit.jupiter.api.Test;
import java.sql.SQLException;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
public class LimboDBTest {
@Test
void db_should_open_normally() throws Exception {
String dbPath = TestUtils.createTempFile();
LimboDB db = LimboDB.create("jdbc:sqlite" + dbPath, dbPath);
db.load();
db.open(0);
}
@Test
void should_throw_exception_when_opened_twice() throws Exception {
String dbPath = TestUtils.createTempFile();
LimboDB db = LimboDB.create("jdbc:sqlite:" + dbPath, dbPath);
db.load();
db.open(0);
assertThatThrownBy(() -> db.open(0)).isInstanceOf(SQLException.class);
}
}