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;