From 8e2bfcd6975924a1b51aa76367dc85632695a591 Mon Sep 17 00:00:00 2001
From: Magnus Persson <1431685+magnusp@users.noreply.github.com>
Date: Fri, 23 Oct 2026 03:36:54 +0200
Subject: [PATCH] test(jdbc): pin post-close statement transitions per java.sql
(#87)
Enumerate the post-close addBatch, getGeneratedKeys, and getResultSet
transitions for plain and prepared statements, and pin each with a
LibsqlStatementLifecycleTest case. The driver already follows java.sql,
so the production change is the class-javadoc enumeration only.
---
.../libsql/jdbc/LibsqlPreparedStatement.java | 8 ++
.../magnusp/libsql/jdbc/LibsqlStatement.java | 6 ++
.../jdbc/LibsqlStatementLifecycleTest.java | 92 +++++++++++++++++++
3 files changed, 106 insertions(+)
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 8f3ecf8..cf82210 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
@@ -28,6 +28,14 @@
import java.util.List;
import java.util.Locale;
+/**
+ * Prepared statement over the Hrana transport.
+ *
+ *
Post-close transitions follow {@link LibsqlStatement}: {@link #addBatch()} and {@link
+ * #getGeneratedKeys()} throw after {@link #close()}. A prepared execute already throws once closed,
+ * so there is no post-close execute that could expose a result set. sqlite-jdbc 3.53.4.0 instead
+ * returns generated keys, and rejects {@code addBatch()} with a different message; see issue #87.
+ */
public class LibsqlPreparedStatement extends LibsqlStatement implements PreparedStatement {
private final String sql;
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 535e2cd..6b12ac8 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
@@ -42,6 +42,12 @@
* working after {@link #close()}. This deliberately copies a likely xerial bug for drop-in
* compatibility; see the issue #47 contract decision. A result set produced after close is
* returned closed, so a closed statement never holds an open result set.
+ *
Every other post-close transition throws {@code SQLException("Statement is closed")}, per
+ * {@code java.sql}: {@link #addBatch(String)}, {@link #getGeneratedKeys()}, and {@link
+ * #getResultSet()}, including the {@code getResultSet} call that follows an execute on the
+ * closed statement. sqlite-jdbc 3.53.4.0 diverges: it accepts {@code addBatch}, returns a
+ * fresh generated-key result set, and reopens its internal pointer so {@code getResultSet}
+ * hands out a live result set. The driver follows {@code java.sql}; see issue #87.
* {@link #close()} and the execute methods are safe when called concurrently. State
* transitions share one lock; when close wins a race, the execute closes and discards its
* result set instead of attaching it to the closed statement.
diff --git a/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlStatementLifecycleTest.java b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlStatementLifecycleTest.java
index 02aea64..7115d0a 100644
--- a/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlStatementLifecycleTest.java
+++ b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlStatementLifecycleTest.java
@@ -219,6 +219,98 @@ void preparedStatementExecuteAfterCloseThrows() throws SQLException {
assertThat(connection.executeCalls).isZero();
}
+ @Test
+ void addBatchAfterCloseThrows() throws SQLException {
+ RecordingConnection connection = new RecordingConnection(ROWS);
+ Statement statement = connection.createStatement();
+ statement.close();
+
+ assertThatThrownBy(() -> statement.addBatch("insert into t(value) values (1)"))
+ .isInstanceOf(SQLException.class)
+ .hasMessage("Statement is closed");
+ assertThat(connection.executeCalls).isZero();
+ }
+
+ @Test
+ void preparedAddBatchAfterCloseThrows() throws SQLException {
+ RecordingConnection connection = new RecordingConnection(ROWS);
+ LibsqlPreparedStatement statement =
+ new LibsqlPreparedStatement(connection, "insert into t(value) values (?)");
+ statement.setLong(1, 1L);
+ statement.close();
+
+ assertThatThrownBy(statement::addBatch)
+ .isInstanceOf(SQLException.class)
+ .hasMessage("Statement is closed");
+ assertThat(connection.executeCalls).isZero();
+ }
+
+ @Test
+ void getGeneratedKeysAfterCloseThrows() throws SQLException {
+ RecordingConnection connection = new RecordingConnection(ROWS);
+ Statement statement = connection.createStatement();
+ statement.execute("insert into t(value) values (1)");
+ statement.close();
+
+ assertThatThrownBy(statement::getGeneratedKeys)
+ .isInstanceOf(SQLException.class)
+ .hasMessage("Statement is closed");
+ }
+
+ @Test
+ void preparedGetGeneratedKeysAfterCloseThrows() throws SQLException {
+ RecordingConnection connection = new RecordingConnection(ROWS);
+ LibsqlPreparedStatement statement =
+ new LibsqlPreparedStatement(connection, "insert into t(value) values (1)");
+ statement.execute();
+ statement.close();
+
+ assertThatThrownBy(statement::getGeneratedKeys)
+ .isInstanceOf(SQLException.class)
+ .hasMessage("Statement is closed");
+ }
+
+ @Test
+ void getResultSetAfterCloseThrows() throws SQLException {
+ RecordingConnection connection = new RecordingConnection(ROWS);
+ Statement statement = connection.createStatement();
+ statement.execute("select value from t");
+ statement.close();
+
+ assertThatThrownBy(statement::getResultSet)
+ .isInstanceOf(SQLException.class)
+ .hasMessage("Statement is closed");
+ }
+
+ @Test
+ void getResultSetAfterPostCloseExecuteThrows() throws SQLException {
+ RecordingConnection connection = new RecordingConnection(ROWS);
+ Statement statement = connection.createStatement();
+ statement.close();
+
+ assertThat(statement.execute("select value from t")).isTrue();
+ assertThat(connection.executeCalls).isEqualTo(1);
+ assertThatThrownBy(statement::getResultSet)
+ .isInstanceOf(SQLException.class)
+ .hasMessage("Statement is closed");
+ }
+
+ @Test
+ void preparedGetResultSetAfterPostCloseExecuteAttemptThrows() throws SQLException {
+ RecordingConnection connection = new RecordingConnection(ROWS);
+ LibsqlPreparedStatement statement =
+ new LibsqlPreparedStatement(connection, "select value from t");
+ statement.close();
+
+ assertThatThrownBy(statement::execute)
+ .isInstanceOf(SQLException.class)
+ .hasMessage("Statement is closed");
+ assertThatThrownBy(statement::getResultSet)
+ .isInstanceOf(SQLException.class)
+ .hasMessage("Statement is closed");
+ assertThat(connection.executeCalls).isZero();
+ }
+
private static final class RecordingConnection extends LibsqlConnection {
private final StmtResult result;
private int executeCalls;