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;