From 9370b8a4b13c154b7944c2080ac65bb38de4960a Mon Sep 17 00:00:00 2001
From: Magnus Persson <1431685+magnusp@users.noreply.github.com>
Date: Fri, 23 Oct 2026 04:06:14 +0200
Subject: [PATCH 1/2] fix(jdbc): pin every silent statement setter (#97)
---
.../magnusp/libsql/jdbc/LibsqlStatement.java | 162 +++++++++++++++++-
.../libsql/jdbc/LibsqlStatementTest.java | 159 +++++++++++++++++
2 files changed, 313 insertions(+), 8 deletions(-)
create mode 100644 libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlStatementTest.java
diff --git a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlStatement.java b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlStatement.java
index 6b12ac8..3d36b05 100644
--- a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlStatement.java
+++ b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlStatement.java
@@ -37,6 +37,16 @@
*
{@link #closeOnCompletion()} and {@link #isCloseOnCompletion()} throw {@code
* SQLException("Statement is closed")} after the statement is closed.
* {@link #close()} closes the current result set and generated-key results.
+ * Optional statement settings are pinned per setter. {@link #setMaxRows(int)} stores a limit
+ * and drops the excess rows from the result set, matching {@code java.sql} and sqlite-jdbc
+ * 3.53.4.0. {@link #setEscapeProcessing(boolean)} stores the java.sql flag, which starts
+ * enabled: while it is enabled, SQL that contains a JDBC escape sequence throws {@link
+ * SQLFeatureNotSupportedException} rather than reaching sqld untranslated. {@link
+ * #setMaxFieldSize(int)} and {@link #setQueryTimeout(int)} accept the zero that means no
+ * limit and throw for a limit the driver cannot enforce. {@link #setCursorName(String)}
+ * always throws: Hrana has no positioned cursor. sqlite-jdbc 3.53.4.0 instead accepts the
+ * last three silently, and its query timeout drives SQLite's busy timeout, which is a lock
+ * wait rather than a query deadline.
* Matching xerial's execute-after-close behavior, {@link #execute(String)}, {@link
* #executeQuery(String)}, {@link #executeUpdate(String)}, and {@link #getConnection()} keep
* working after {@link #close()}. This deliberately copies a likely xerial bug for drop-in
@@ -66,6 +76,8 @@ public class LibsqlStatement implements Statement {
private boolean resultSetRequested = false;
private boolean closing = false;
private int fetchSize = 0;
+ private int maxRows = 0;
+ private boolean escapeProcessing = true;
public LibsqlStatement(LibsqlConnection connection) {
this.connection = connection;
@@ -94,7 +106,7 @@ public ResultSet executeQuery(String sql) throws SQLException {
if (result.cols() == null || result.cols().isEmpty()) {
throw new SQLException("Query did not produce a ResultSet: " + sql);
}
- LibsqlResultSet resultSet = new LibsqlResultSet(this, result, sql, false);
+ LibsqlResultSet resultSet = new LibsqlResultSet(this, limitRows(result), sql, false);
assignResultSet(resultSet, result, sql, true);
return resultSet;
}
@@ -139,7 +151,7 @@ public boolean execute(String sql) throws SQLException {
protected boolean executeResult(String sql, StmtResult result) throws SQLException {
boolean hasResultSet = result.cols() != null && !result.cols().isEmpty();
LibsqlResultSet resultSet =
- hasResultSet ? new LibsqlResultSet(this, result, sql, false) : null;
+ hasResultSet ? new LibsqlResultSet(this, limitRows(result), sql, false) : null;
if (hasResultSet && !isClosed()) {
beforeResultSetAssignment();
@@ -165,10 +177,106 @@ protected boolean executeResult(String sql, StmtResult result) throws SQLExcepti
}
private StmtResult executeSql(String sql) throws SQLException {
+ checkEscapeProcessing(sql);
beginExecution();
return connection.executeStatement(new Stmt(sql));
}
+ /**
+ * Rejects SQL that needs JDBC escape substitution while escape processing is on. The driver
+ * sends SQL to sqld verbatim, so a sequence such as {@code {fn abs(-1)}} would fail as SQLite
+ * syntax instead of being translated. Turning escape processing off is how a caller states
+ * that it sends raw SQL.
+ */
+ private void checkEscapeProcessing(String sql) throws SQLException {
+ boolean enabled;
+ synchronized (stateLock) {
+ enabled = escapeProcessing;
+ }
+ if (enabled && hasEscapeSequence(sql)) {
+ throw new SQLFeatureNotSupportedException("JDBC escape sequences are not supported");
+ }
+ }
+
+ /**
+ * Whether the SQL contains a JDBC escape sequence. Braces inside string literals, quoted
+ * identifiers, and comments do not count, and a brace that does not start with an escape code
+ * is left to the server.
+ */
+ private static boolean hasEscapeSequence(String sql) {
+ if (sql == null || sql.indexOf('{') < 0) {
+ return false;
+ }
+ int n = sql.length();
+ int i = 0;
+ while (i < n) {
+ char c = sql.charAt(i);
+ if (c == '\'' || c == '"' || c == '`') {
+ i++;
+ while (i < n) {
+ if (sql.charAt(i) == c) {
+ if (i + 1 < n && sql.charAt(i + 1) == c) {
+ i += 2;
+ continue;
+ }
+ i++;
+ break;
+ }
+ i++;
+ }
+ } else if (c == '[') {
+ i++;
+ while (i < n && sql.charAt(i) != ']') {
+ i++;
+ }
+ i++;
+ } else if (c == '-' && i + 1 < n && sql.charAt(i + 1) == '-') {
+ i += 2;
+ while (i < n && sql.charAt(i) != '\n') {
+ i++;
+ }
+ } else if (c == '/' && i + 1 < n && sql.charAt(i + 1) == '*') {
+ i += 2;
+ while (i + 1 < n && !(sql.charAt(i) == '*' && sql.charAt(i + 1) == '/')) {
+ i++;
+ }
+ i += 2;
+ } else if (c == '{') {
+ int code = i + 1;
+ while (code < n && Character.isWhitespace(sql.charAt(code))) {
+ code++;
+ }
+ if (code < n && "dDtTfFoOeEcC".indexOf(sql.charAt(code)) >= 0) {
+ return true;
+ }
+ i++;
+ } else {
+ i++;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Drops rows above the {@link #setMaxRows(int)} limit. java.sql requires the excess rows to be
+ * dropped silently from the result set, not from the statement that produced it, so the server
+ * still runs the full query.
+ */
+ private StmtResult limitRows(StmtResult result) {
+ int limit;
+ synchronized (stateLock) {
+ limit = maxRows;
+ }
+ if (limit <= 0 || result == null || result.rows() == null || result.rows().size() <= limit) {
+ return result;
+ }
+ return new StmtResult(
+ result.cols(),
+ new ArrayList<>(result.rows().subList(0, limit)),
+ result.affectedRowCount(),
+ result.lastInsertRowid());
+ }
+
/**
* Resets the state every execution path starts from: the previous result set is closed and the
* previous generated keys are discarded. Without this, a path that does not produce keys of
@@ -411,6 +519,7 @@ public boolean getMoreResults() throws SQLException {
@Override
public void addBatch(String sql) throws SQLException {
checkClosed();
+ checkEscapeProcessing(sql);
batchStatements.add(new Stmt(sql));
}
@@ -485,16 +594,53 @@ public boolean isClosed() throws SQLException {
}
@Override public int getMaxFieldSize() throws SQLException { return 0; }
- @Override public void setMaxFieldSize(int max) throws SQLException {}
- @Override public int getMaxRows() throws SQLException { return 0; }
- @Override public void setMaxRows(int max) throws SQLException {}
- @Override public void setEscapeProcessing(boolean enable) throws SQLException {}
+ @Override
+ public void setMaxFieldSize(int max) throws SQLException {
+ if (max < 0) {
+ throw new SQLException("max field size must be >= 0");
+ }
+ if (max > 0) {
+ throw new SQLFeatureNotSupportedException("Column size limits are not supported");
+ }
+ }
+ @Override
+ public int getMaxRows() throws SQLException {
+ synchronized (stateLock) {
+ return maxRows;
+ }
+ }
+ @Override
+ public void setMaxRows(int max) throws SQLException {
+ if (max < 0) {
+ throw new SQLException("max row count must be >= 0");
+ }
+ synchronized (stateLock) {
+ maxRows = max;
+ }
+ }
+ @Override
+ public void setEscapeProcessing(boolean enable) throws SQLException {
+ synchronized (stateLock) {
+ escapeProcessing = enable;
+ }
+ }
@Override public int getQueryTimeout() throws SQLException { return 0; }
- @Override public void setQueryTimeout(int seconds) throws SQLException {}
+ @Override
+ public void setQueryTimeout(int seconds) throws SQLException {
+ if (seconds < 0) {
+ throw new SQLException("query timeout must be >= 0");
+ }
+ if (seconds > 0) {
+ throw new SQLFeatureNotSupportedException("Query timeouts are not supported");
+ }
+ }
@Override public void cancel() throws SQLException {}
@Override public SQLWarning getWarnings() throws SQLException { return null; }
@Override public void clearWarnings() throws SQLException {}
- @Override public void setCursorName(String name) throws SQLException {}
+ @Override
+ public void setCursorName(String name) throws SQLException {
+ throw new SQLFeatureNotSupportedException("Cursor names are not supported");
+ }
@Override public void setFetchDirection(int direction) throws SQLException {}
@Override public int getFetchDirection() throws SQLException { return ResultSet.FETCH_FORWARD; }
@Override
diff --git a/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlStatementTest.java b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlStatementTest.java
new file mode 100644
index 0000000..bf313b8
--- /dev/null
+++ b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlStatementTest.java
@@ -0,0 +1,159 @@
+package com.github.magnusp.libsql.jdbc;
+
+import com.github.magnusp.libsql.hrana.codec.Col;
+import com.github.magnusp.libsql.hrana.codec.Row;
+import com.github.magnusp.libsql.hrana.codec.Stmt;
+import com.github.magnusp.libsql.hrana.codec.StmtResult;
+import com.github.magnusp.libsql.hrana.codec.Value;
+import org.junit.jupiter.api.Test;
+
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.SQLFeatureNotSupportedException;
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class LibsqlStatementTest {
+
+ private static final StmtResult THREE_ROWS = new StmtResult(
+ List.of(new Col("value", null)),
+ List.of(
+ new Row(List.of(Value.of(1L))),
+ new Row(List.of(Value.of(2L))),
+ new Row(List.of(Value.of(3L)))),
+ 0,
+ null);
+
+ @Test
+ void setMaxRowsTruncatesResultRowsAndGetMaxRowsReadsItBack() throws SQLException {
+ LibsqlStatement statement = new LibsqlStatement(new RecordingConnection(THREE_ROWS));
+
+ statement.setMaxRows(2);
+
+ assertThat(statement.getMaxRows()).isEqualTo(2);
+ try (ResultSet resultSet = statement.executeQuery("select value from t")) {
+ assertThat(resultSet.next()).isTrue();
+ assertThat(resultSet.getLong(1)).isEqualTo(1L);
+ assertThat(resultSet.next()).isTrue();
+ assertThat(resultSet.getLong(1)).isEqualTo(2L);
+ assertThat(resultSet.next()).isFalse();
+ }
+ }
+
+ @Test
+ void setMaxRowsZeroRemovesTheLimit() throws SQLException {
+ LibsqlStatement statement = new LibsqlStatement(new RecordingConnection(THREE_ROWS));
+ statement.setMaxRows(1);
+
+ statement.setMaxRows(0);
+
+ assertThat(statement.getMaxRows()).isZero();
+ int rows = 0;
+ try (ResultSet resultSet = statement.executeQuery("select value from t")) {
+ while (resultSet.next()) {
+ rows++;
+ }
+ }
+ assertThat(rows).isEqualTo(3);
+ }
+
+ @Test
+ void setMaxRowsRejectsANegativeLimit() {
+ LibsqlStatement statement = new LibsqlStatement(new RecordingConnection(THREE_ROWS));
+
+ assertThatThrownBy(() -> statement.setMaxRows(-1))
+ .isInstanceOf(SQLException.class)
+ .hasMessage("max row count must be >= 0");
+ }
+
+ @Test
+ void setQueryTimeoutAcceptsNoLimitAndRejectsAPositiveTimeout() throws SQLException {
+ LibsqlStatement statement = new LibsqlStatement(new RecordingConnection(THREE_ROWS));
+
+ statement.setQueryTimeout(0);
+
+ assertThat(statement.getQueryTimeout()).isZero();
+ assertThatThrownBy(() -> statement.setQueryTimeout(1))
+ .isInstanceOf(SQLFeatureNotSupportedException.class)
+ .hasMessage("Query timeouts are not supported");
+ assertThatThrownBy(() -> statement.setQueryTimeout(-1))
+ .isInstanceOf(SQLException.class)
+ .hasMessage("query timeout must be >= 0");
+ }
+
+ @Test
+ void setMaxFieldSizeAcceptsNoLimitAndRejectsAPositiveLimit() throws SQLException {
+ LibsqlStatement statement = new LibsqlStatement(new RecordingConnection(THREE_ROWS));
+
+ statement.setMaxFieldSize(0);
+
+ assertThat(statement.getMaxFieldSize()).isZero();
+ assertThatThrownBy(() -> statement.setMaxFieldSize(8))
+ .isInstanceOf(SQLFeatureNotSupportedException.class)
+ .hasMessage("Column size limits are not supported");
+ assertThatThrownBy(() -> statement.setMaxFieldSize(-1))
+ .isInstanceOf(SQLException.class)
+ .hasMessage("max field size must be >= 0");
+ }
+
+ @Test
+ void escapeProcessingStartsEnabledAndRejectsEscapeSequences() {
+ RecordingConnection connection = new RecordingConnection(THREE_ROWS);
+ LibsqlStatement statement = new LibsqlStatement(connection);
+
+ assertThatThrownBy(() -> statement.executeQuery("select {fn abs(-1)}"))
+ .isInstanceOf(SQLFeatureNotSupportedException.class)
+ .hasMessage("JDBC escape sequences are not supported");
+ assertThat(connection.executedSql).isEmpty();
+ }
+
+ @Test
+ void setEscapeProcessingDisabledSendsEscapeLookingSqlVerbatim() throws SQLException {
+ RecordingConnection connection = new RecordingConnection(THREE_ROWS);
+ LibsqlStatement statement = new LibsqlStatement(connection);
+
+ statement.setEscapeProcessing(false);
+ statement.executeQuery("select '{fn abs(-1)}' as text");
+
+ assertThat(connection.executedSql)
+ .containsExactly("select '{fn abs(-1)}' as text");
+ }
+
+ @Test
+ void escapeProcessingIgnoresBracesInsideStringLiteralsAndComments() throws SQLException {
+ RecordingConnection connection = new RecordingConnection(THREE_ROWS);
+ LibsqlStatement statement = new LibsqlStatement(connection);
+
+ statement.executeQuery("select '{\"a\": 1}' as doc -- {fn abs(-1)}");
+
+ assertThat(connection.executedSql).hasSize(1);
+ }
+
+ @Test
+ void setCursorNameIsNotSupported() {
+ LibsqlStatement statement = new LibsqlStatement(new RecordingConnection(THREE_ROWS));
+
+ assertThatThrownBy(() -> statement.setCursorName("c1"))
+ .isInstanceOf(SQLFeatureNotSupportedException.class)
+ .hasMessage("Cursor names are not supported");
+ }
+
+ private static final class RecordingConnection extends LibsqlConnection {
+ private final StmtResult result;
+ private final List executedSql = new ArrayList<>();
+
+ private RecordingConnection(StmtResult result) {
+ super(null, null);
+ this.result = result;
+ }
+
+ @Override
+ public StmtResult executeStatement(Stmt stmt) {
+ executedSql.add(stmt.sql());
+ return result;
+ }
+ }
+}
From 2c301f6d4be1d80e6adb42d36db4f491ab772c7f Mon Sep 17 00:00:00 2001
From: Magnus Persson <1431685+magnusp@users.noreply.github.com>
Date: Fri, 23 Oct 2026 04:11:13 +0200
Subject: [PATCH 2/2] fix(jdbc): apply max rows and escape checks to prepared
statements (#97)
---
.../libsql/jdbc/LibsqlPreparedStatement.java | 3 +-
.../magnusp/libsql/jdbc/LibsqlStatement.java | 4 +-
.../jdbc/LibsqlPreparedStatementTest.java | 48 +++++++++++++++++++
3 files changed, 52 insertions(+), 3 deletions(-)
diff --git a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlPreparedStatement.java b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlPreparedStatement.java
index c4b4bf0..2dcb12d 100644
--- a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlPreparedStatement.java
+++ b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlPreparedStatement.java
@@ -72,7 +72,7 @@ public ResultSet executeQuery() throws SQLException {
if (result.cols() == null || result.cols().isEmpty()) {
throw new SQLException("Query does not return results");
}
- LibsqlResultSet resultSet = new LibsqlResultSet(this, result, sql, false);
+ LibsqlResultSet resultSet = new LibsqlResultSet(this, limitRows(result), sql, false);
assignResultSet(resultSet, result, sql, true);
return resultSet;
}
@@ -229,6 +229,7 @@ public boolean execute() throws SQLException {
}
private StmtResult executeCurrent() throws SQLException {
+ checkEscapeProcessing(sql);
checkClosed();
beginExecution();
return connection.executeStatement(buildCurrentStmt());
diff --git a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlStatement.java b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlStatement.java
index 3d36b05..271e175 100644
--- a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlStatement.java
+++ b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlStatement.java
@@ -188,7 +188,7 @@ private StmtResult executeSql(String sql) throws SQLException {
* syntax instead of being translated. Turning escape processing off is how a caller states
* that it sends raw SQL.
*/
- private void checkEscapeProcessing(String sql) throws SQLException {
+ protected void checkEscapeProcessing(String sql) throws SQLException {
boolean enabled;
synchronized (stateLock) {
enabled = escapeProcessing;
@@ -262,7 +262,7 @@ private static boolean hasEscapeSequence(String sql) {
* dropped silently from the result set, not from the statement that produced it, so the server
* still runs the full query.
*/
- private StmtResult limitRows(StmtResult result) {
+ protected StmtResult limitRows(StmtResult result) {
int limit;
synchronized (stateLock) {
limit = maxRows;
diff --git a/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlPreparedStatementTest.java b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlPreparedStatementTest.java
index 2c880bd..ad08f45 100644
--- a/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlPreparedStatementTest.java
+++ b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlPreparedStatementTest.java
@@ -9,6 +9,8 @@
import java.sql.ResultSet;
import java.sql.SQLException;
+import java.sql.SQLFeatureNotSupportedException;
+import java.util.ArrayList;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@@ -24,6 +26,15 @@ class LibsqlPreparedStatementTest {
private static final StmtResult NO_ROWS = new StmtResult(List.of(), List.of(), 1, 1L);
+ private static final StmtResult THREE_ROWS = new StmtResult(
+ List.of(new Col("id", "INTEGER")),
+ List.of(
+ new Row(List.of(Value.of(1L))),
+ new Row(List.of(Value.of(2L))),
+ new Row(List.of(Value.of(3L)))),
+ 0,
+ null);
+
@Test
void executeQueryOnInsertThrowsWithoutExecuting() throws SQLException {
RecordingConnection connection = new RecordingConnection(NO_ROWS);
@@ -102,8 +113,44 @@ void executeUpdateOnWithSelectThrowsWithoutExecuting() throws SQLException {
assertThat(connection.executeCalls).isZero();
}
+ @Test
+ void setMaxRowsLimitsPreparedQueryAndGetMaxRowsReadsItBack() throws SQLException {
+ RecordingConnection connection = new RecordingConnection(THREE_ROWS);
+ try (LibsqlPreparedStatement statement =
+ new LibsqlPreparedStatement(connection, "select id from t")) {
+ statement.setMaxRows(2);
+
+ assertThat(statement.getMaxRows()).isEqualTo(2);
+ ResultSet resultSet = statement.executeQuery();
+ assertThat(resultSet.next()).isTrue();
+ assertThat(resultSet.getLong(1)).isEqualTo(1L);
+ assertThat(resultSet.next()).isTrue();
+ assertThat(resultSet.getLong(1)).isEqualTo(2L);
+ assertThat(resultSet.next()).isFalse();
+ }
+ }
+
+ @Test
+ void escapeProcessingAppliesToPreparedSql() throws SQLException {
+ RecordingConnection connection = new RecordingConnection(THREE_ROWS);
+ try (LibsqlPreparedStatement statement =
+ new LibsqlPreparedStatement(connection, "select {fn abs(-1)} from t")) {
+ assertThatThrownBy(statement::executeQuery)
+ .isInstanceOf(SQLFeatureNotSupportedException.class)
+ .hasMessage("JDBC escape sequences are not supported");
+ assertThat(connection.executeCalls).isZero();
+
+ statement.setEscapeProcessing(false);
+ statement.executeQuery();
+
+ assertThat(connection.executedSql)
+ .containsExactly("select {fn abs(-1)} from t");
+ }
+ }
+
private static final class RecordingConnection extends LibsqlConnection {
private final StmtResult result;
+ private final List executedSql = new ArrayList<>();
private int executeCalls;
private RecordingConnection(StmtResult result) {
@@ -114,6 +161,7 @@ private RecordingConnection(StmtResult result) {
@Override
public StmtResult executeStatement(Stmt stmt) {
executeCalls++;
+ executedSql.add(stmt.sql());
return result;
}
}