Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ foreach(artifact_spec IN LISTS LBUG_MAVEN_ARTIFACTS)
list(APPEND LBUG_MAVEN_JARS "${jar_path}")
endforeach()

set(CMAKE_JAVA_COMPILE_FLAGS -source 1.8 -target 1.8 -encoding utf-8)
set(CMAKE_JAVA_COMPILE_FLAGS -source 21 -target 21 -encoding utf-8)
add_jar(lbug_java ${JAVA_SRC_FILES}
INCLUDE_JARS ${LBUG_MAVEN_JARS}
OUTPUT_DIR "${PROJECT_SOURCE_DIR}/build"
Expand Down
19 changes: 16 additions & 3 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,19 @@ plugins {
java {
withSourcesJar()
withJavadocJar()
// Pattern-matching switch in Connection.coerceParam requires Java 21+.
// Keep this in sync with CMAKE_JAVA_COMPILE_FLAGS in CMakeLists.txt and
// the java.version / maven.compiler.{source,target} POM properties below.
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}

// Kotlin must target the same JVM level as Java, otherwise Gradle refuses
// to build (inconsistent jvm-target between compileTestJava and compileTestKotlin).
tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach {
kotlinOptions {
jvmTarget = '21'
}
}

group = 'com.ladybugdb'
Expand Down Expand Up @@ -63,9 +76,9 @@ publishing {
properties = [
'project.build.sourceEncoding' : 'UTF-8',
'project.reporting.outputEncoding': 'UTF-8',
'java.version' : '11',
'maven.compiler.source' : '11',
'maven.compiler.target' : '11'
'java.version' : '21',
'maven.compiler.source' : '21',
'maven.compiler.target' : '21'
]
licenses {
license {
Expand Down
23 changes: 22 additions & 1 deletion src/jni/lbug_java.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -513,7 +513,25 @@ void bindJavaParamsToPreparedStatement(JNIEnv* env, lbug_prepared_statement* pre
jstring key = (jstring)callObjectMethodChecked(env, entry, J_C_Map$Entry_M_getKey);
jobject value = callObjectMethodChecked(env, entry, J_C_Map$Entry_M_getValue);
std::string keyStr = jstringToUtf8String(env, key);
auto* clonedValue = lbug_value_clone(getValue(env, value));

// The Java side (Connection.coerceParams) guarantees that every entry
// is already a Value — boxed primitives are converted there before the
// JNI call. We keep the IsInstanceOf check as a cheap contract guard:
// if it ever fails, something bypassed the public API and we'd rather
// fail loud than reinterpret_cast into the void.
if (!env->IsInstanceOf(value, J_C_Value)) {
env->DeleteLocalRef(entry);
env->DeleteLocalRef(key);
env->DeleteLocalRef(value);
throwJNIException(env,
("Parameter '" + keyStr
+ "' is not a Value — Connection.execute must be used as the entry point")
.c_str());
return;
}

lbug_value* clonedValue = lbug_value_clone(getValue(env, value));

auto state =
lbug_prepared_statement_bind_value(preparedStatement, keyStr.c_str(), clonedValue);
lbug_value_destroy(clonedValue);
Expand Down Expand Up @@ -769,6 +787,9 @@ JNIEXPORT jobject JNICALL Java_com_ladybugdb_Native_lbugConnectionExecute(JNIEnv
auto* conn = getConnection(env, thisConn);
auto* ps = getPreparedStatement(env, preStm);
bindJavaParamsToPreparedStatement(env, ps, paramMap);
if (env->ExceptionCheck()) {
return jobject();
}
auto* queryResult = new lbug_query_result();
lbug_state state = lbug_connection_execute(conn, ps, queryResult);
if (state != LbugSuccess && queryResult->_query_result == nullptr) {
Expand Down
88 changes: 81 additions & 7 deletions src/main/java/com/lbugdb/Connection.java
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
package com.ladybugdb;

import java.util.Map;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDate;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;

import org.apache.arrow.c.ArrowSchema;
import org.apache.arrow.memory.BufferAllocator;
Expand Down Expand Up @@ -107,15 +114,82 @@ public PreparedStatement prepare(String queryStr) {
/**
* Executes the given prepared statement with args and returns the result.
*
* @param ps: The prepared statement to execute.
* @param m: The parameter pack where each arg is a std::pair with the first element being parameter name and second
* element being parameter value
* <p>Values that are not already {@link Value} instances are automatically converted from
* their boxed Java type (e.g. {@link String}, {@link Long}, {@link java.util.UUID}, etc.).
* If a value's type is not supported, an {@link IllegalArgumentException} is thrown instead
* of crashing the JVM.
*
* @param ps The prepared statement to execute.
* @param params The parameter map. Each value must be a {@link Value} or one of the
* supported boxed types: Boolean, Byte, Short, Integer, Long, BigInteger,
* Float, Double, BigDecimal, String, InternalID, UUID, LocalDate, Instant,
* Duration.
* @return The result of the query.
* @throws RuntimeException If the connection has been destroyed.
* @throws RuntimeException If the connection has been destroyed.
* @throws IllegalArgumentException If a parameter value has an unsupported type.
*/
public QueryResult execute(PreparedStatement ps, Map<String, Value> m) {
public QueryResult execute(PreparedStatement ps, Map<String, ?> params) {
checkNotDestroyed();
return Native.lbugConnectionExecute(this, ps, m);
return Native.lbugConnectionExecute(this, ps, coerceParams(params));
}

/**
* Convert the user-supplied {@code Map<String, ?>} into the strict
* {@code Map<String, Value>} shape the JNI binding expects. Already-wrapped
* {@link Value} instances are passed through (the JNI will clone them when
* binding); boxed Java types are auto-converted via {@link Value#Value}.
*
* <p>Doing the conversion on the Java side keeps the public API ergonomic
* ({@code Map<String, ?>} instead of forcing users into a raw-type cast)
* while letting the native binding stay strictly typed — no
* {@code @SuppressWarnings("unchecked")} required, and the conversion logic
* is testable without round-tripping through JNI.
*/
private static Map<String, Value> coerceParams(Map<String, ?> params) {
Map<String, Value> coerced = new LinkedHashMap<>(params.size());
for (Map.Entry<String, ?> e : params.entrySet()) {
coerced.put(e.getKey(), coerceParam(e.getKey(), e.getValue()));
}
return coerced;
}

private static Value coerceParam(String key, Object v) {
// Pattern-matching switch (JEP 441, GA in Java 21): the JIT lowers
// this to a single type-table jump, so dispatch is O(1) per entry
// regardless of how many supported types we add. Hot path for users
// who bind a `Map<String, ?>` of mostly-uniform types — e.g. 1000
// `Long` parameters — and previously paid 15 instanceof checks
// per element.
return switch (v) {
case null ->
// The JNI path used to surface null as IllegalArgumentException
// ("unsupported type null"); preserve that contract. Users who
// want a SQL NULL must build one explicitly via
// {@link Value#createNull()}.
throw new IllegalArgumentException(
"Parameter '" + key + "' is null; use Value.createNull() to bind SQL NULL.");
case Value value -> value;
case Boolean box -> new Value(box);
case Byte box -> new Value(box);
case Short box -> new Value(box);
case Integer box -> new Value(box);
case Long box -> new Value(box);
case BigInteger box -> new Value(box);
case Float box -> new Value(box);
case Double box -> new Value(box);
case BigDecimal box -> new Value(box);
case String box -> new Value(box);
case InternalID box -> new Value(box);
case UUID box -> new Value(box);
case LocalDate box -> new Value(box);
case Instant box -> new Value(box);
case Duration box -> new Value(box);
default -> throw new IllegalArgumentException(
"Parameter '" + key + "' has unsupported type " + v.getClass().getName()
+ ". Accepted types: Value, Boolean, Byte, Short, Integer, Long, "
+ "BigInteger, Float, Double, BigDecimal, String, InternalID, UUID, "
+ "LocalDate, Instant, Duration");
};
}

/**
Expand Down
150 changes: 149 additions & 1 deletion src/test/java/com/lbugdb/PreparedStatementTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@

import static org.junit.jupiter.api.Assertions.*;

import java.util.Map;
import java.util.*;
import java.util.stream.Collectors;

public class PreparedStatementTest extends TestBase {

Expand Down Expand Up @@ -61,4 +62,151 @@ void PrepStmtSpecialString() {
}
}

@Test
void executeWithRawBoxedString() {
// Option A: raw String value auto-converted by C++ layer
String query = "MATCH (n:person) WHERE n.fName = $1 RETURN n.fName";
try (PreparedStatement ps = conn.prepare(query)) {
Map<String, Object> raw = new HashMap<>();
raw.put("1", "Alice");
@SuppressWarnings("unchecked")
Map<String, ?> params = (Map<String, ?>) (Map<?, ?>) raw;
QueryResult result = conn.execute(ps, params);
assertTrue(result.isSuccess());
assertTrue(result.hasNext());
String got = result.getNext().getValue(0).getValue();
assertEquals("Alice", got);
}
}

@Test
void executeWithRawBoxedLong() {
// Option A: raw Long value auto-converted by C++ layer
String query = "MATCH (n:person) WHERE n.ID = $1 RETURN n.fName";
try (PreparedStatement ps = conn.prepare(query)) {
Map<String, Object> raw = new HashMap<>();
raw.put("1", 0L);
@SuppressWarnings("unchecked")
Map<String, ?> params = (Map<String, ?>) (Map<?, ?>) raw;
QueryResult result = conn.execute(ps, params);
assertTrue(result.isSuccess());
assertTrue(result.hasNext());
String got = result.getNext().getValue(0).getValue();
assertEquals("Alice", got);
}
}

@Test
void executeWithRawBoxedDouble() {
// Option A: raw Double value auto-converted
String query = "MATCH (n:person) WHERE n.eyeSight = $1 RETURN n.fName";
try (PreparedStatement ps = conn.prepare(query)) {
Map<String, Object> raw = new HashMap<>();
raw.put("1", 5.0);
@SuppressWarnings("unchecked")
Map<String, ?> params = (Map<String, ?>) (Map<?, ?>) raw;
QueryResult result = conn.execute(ps, params);
assertTrue(result.isSuccess());
assertTrue(result.hasNext());
String got = result.getNext().getValue(0).getValue();
assertEquals("Alice", got);
}
}

@Test
void executeWithRawBoxedBoolean() {
// Option A: raw Boolean value auto-converted
String query = "MATCH (n:person) WHERE n.isStudent = $1 RETURN n.fName";
try (PreparedStatement ps = conn.prepare(query)) {
Map<String, Object> raw = new HashMap<>();
raw.put("1", true);
@SuppressWarnings("unchecked")
Map<String, ?> params = (Map<String, ?>) (Map<?, ?>) raw;
QueryResult result = conn.execute(ps, params);
assertTrue(result.isSuccess());
assertTrue(result.hasNext());
}
}

@Test
void executeWithUnsupportedTypeThrows() {
// Option B: unsupported type throws IllegalArgumentException instead of crashing
String query = "MATCH (n:person) WHERE n.fName = $1 RETURN n.fName";
try (PreparedStatement ps = conn.prepare(query)) {
Map<String, Object> raw = new HashMap<>();
raw.put("1", new ArrayList<>(List.of("not", "supported")));
@SuppressWarnings("unchecked")
Map<String, ?> params = (Map<String, ?>) (Map<?, ?>) raw;
assertThrows(IllegalArgumentException.class, () -> {
conn.execute(ps, params);
});
}
}

@Test
void executeWithValueWrappedParamsRegression() {
// Regression: Value-wrapped path is unchanged
String query = "MATCH (n:person) WHERE n.fName = $1 RETURN n.fName";
try (PreparedStatement ps = conn.prepare(query)) {
Map<String, Value> params = Map.of("1", new Value("Alice"));
QueryResult result = conn.execute(ps, params);
assertTrue(result.isSuccess());
assertTrue(result.hasNext());
String got = result.getNext().getValue(0).getValue();
assertEquals("Alice", got);
}
}

@Test
void executeWithMixedRawAndValueParams() {
// Option A: mixed raw and Value-wrapped params
String query = "MATCH (n:person) WHERE n.fName = $1 AND n.age = $2 RETURN n.fName";
try (PreparedStatement ps = conn.prepare(query)) {
Map<String, Object> raw = new HashMap<>();
raw.put("1", new Value("Alice"));
raw.put("2", 35L);
@SuppressWarnings("unchecked")
Map<String, ?> params = (Map<String, ?>) (Map<?, ?>) raw;
QueryResult result = conn.execute(ps, params);
assertTrue(result.isSuccess());
assertTrue(result.hasNext());
}
}

@Test
void executeWithNullParamThrows() {
// After the refactor, nulls are caught on the Java side with a clear
// error message naming the offending key, instead of crashing inside
// the JNI conversion ladder.
String query = "MATCH (n:person) WHERE n.fName = $1 RETURN n.fName";
try (PreparedStatement ps = conn.prepare(query)) {
Map<String, Object> raw = new HashMap<>();
raw.put("1", null);
@SuppressWarnings("unchecked")
Map<String, ?> params = (Map<String, ?>) (Map<?, ?>) raw;
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> {
conn.execute(ps, params);
});
assertTrue(ex.getMessage().contains("'1'"),
"exception should name the offending key, got: " + ex.getMessage());
}
}

@Test
void executeWithValueCreateNullParam() {
// Value.createNull() is the explicit way to bind a SQL NULL; it should
// round-trip cleanly through the new coercion path.
String query = "MATCH (n:person) WHERE n.fName = $1 RETURN n.fName";
try (PreparedStatement ps = conn.prepare(query)) {
Map<String, Object> raw = new HashMap<>();
raw.put("1", Value.createNull());
@SuppressWarnings("unchecked")
Map<String, ?> params = (Map<String, ?>) (Map<?, ?>) raw;
QueryResult result = conn.execute(ps, params);
assertTrue(result.isSuccess());
// SQL NULL never matches n.fName, so no rows.
assertFalse(result.hasNext());
}
}

}
Loading