diff --git a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlConnection.java b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlConnection.java index 879d6b1..56f017b 100644 --- a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlConnection.java +++ b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlConnection.java @@ -71,6 +71,9 @@ public class LibsqlConnection implements Connection { private boolean closed = false; private TransactionMode transactionMode = TransactionMode.DEFERRED; private LibsqlDatabaseMetaData meta; + private int nextSavepointId = 0; + private boolean transactionStartedBySavepoint = false; + private final List savepointNames = new ArrayList<>(); /** * Transaction begin mode, matching xerial sqlite-jdbc's @@ -439,6 +442,7 @@ private void endTransaction(String sql) throws SQLException { // The server committed or rolled back, so the stream has no state left worth holding. // Closing it lets an idle connection drop its server-side stream. On an error above the // baton stays, so the caller can retry or roll back on the same stream. + clearSavepoints(); closeStreamSilently(); } @@ -447,6 +451,7 @@ public void close() throws SQLException { synchronized (stateLock) { if (!closed) { closed = true; + clearSavepoints(); closeStreamSilently(); } } @@ -592,24 +597,154 @@ private static void checkHoldability(int holdability) throws SQLException { } } + /** + * Creates an unnamed savepoint. When the connection is in auto-commit mode this starts a + * transaction, matching xerial sqlite-jdbc 3.53.4.0 and SQLite itself: a {@code SAVEPOINT} + * issued outside a transaction behaves as {@code BEGIN DEFERRED}, so the savepoint is sent on + * a new Hrana stream without a preceding {@code BEGIN} and releasing that outermost savepoint + * later commits. Inside a transaction begun by {@link #setAutoCommit(boolean)} the savepoint + * is nested in the lazily begun transaction instead. + */ @Override public Savepoint setSavepoint() throws SQLException { - throw new SQLException("Savepoints not supported yet"); + return setSavepointInternal(null); } @Override public Savepoint setSavepoint(String name) throws SQLException { - throw new SQLException("Savepoints not supported yet"); + synchronized (stateLock) { + checkClosed(); + if (name == null || name.isBlank()) { + throw new SQLException("Savepoint name cannot be null or empty"); + } + } + return setSavepointInternal(name); + } + + private Savepoint setSavepointInternal(String name) throws SQLException { + synchronized (stateLock) { + checkClosed(); + LibsqlSavepoint savepoint = new LibsqlSavepoint(++nextSavepointId, name); + String sql = "SAVEPOINT " + quoteSavepointName(savepoint.sqlName()); + if (autoCommit) { + try { + executeTransactionStatement(sql); + } catch (SQLException e) { + // No transaction was started, so the fresh stream holds no state. + closeStreamSilently(); + throw e; + } + autoCommit = false; + transactionStartedBySavepoint = true; + savepointNames.clear(); + savepointNames.add(savepoint.sqlName()); + } else { + ensureTransactionStarted(); + executeTransactionStatement(sql); + savepointNames.add(savepoint.sqlName()); + } + return savepoint; + } } @Override public void rollback(Savepoint savepoint) throws SQLException { - throw new SQLException("Savepoints not supported yet"); + synchronized (stateLock) { + checkClosed(); + String sqlName = savepointSqlName(savepoint); + if (autoCommit) { + throw new SQLException("Cannot rollback to a savepoint when in autoCommit mode"); + } + ensureTransactionStarted(); + executeTransactionStatement("ROLLBACK TO SAVEPOINT " + quoteSavepointName(sqlName)); + int index = savepointNames.lastIndexOf(sqlName); + if (index >= 0) { + // Rolling back cancels the savepoints created after the target; the target + // itself stays active. + savepointNames.subList(index + 1, savepointNames.size()).clear(); + } + } } @Override public void releaseSavepoint(Savepoint savepoint) throws SQLException { - throw new SQLException("Savepoints not supported yet"); + synchronized (stateLock) { + checkClosed(); + String sqlName = savepointSqlName(savepoint); + if (autoCommit) { + throw new SQLException("Cannot release a savepoint when in autoCommit mode"); + } + ensureTransactionStarted(); + executeTransactionStatement("RELEASE SAVEPOINT " + quoteSavepointName(sqlName)); + int index = savepointNames.lastIndexOf(sqlName); + if (index >= 0) { + boolean outermost = index == 0; + savepointNames.subList(index, savepointNames.size()).clear(); + if (outermost && transactionStartedBySavepoint) { + // Releasing the savepoint that started the transaction commits it, so the + // stream holds no transaction state and the next statement lazily begins a + // new one, as after a commit. + transactionStartedBySavepoint = false; + savepointNames.clear(); + closeStreamSilently(); + } + } + } + } + + /** Callers hold {@link #stateLock}. */ + private void executeTransactionStatement(String sql) throws SQLException { + PipelineRespBody resp; + try { + PipelineReqBody req = new PipelineReqBody(baton, List.of(new StreamRequest.Execute(new Stmt(sql)))); + resp = client.sendPipeline(req, baseUrl); + } catch (LibsqlException e) { + throw LibsqlErrorMapper.toSQLException(e); + } + updateStreamState(resp); + if (resp.results().isEmpty()) { + throw new SQLException("Empty result received from server"); + } + StreamResult result = resp.results().get(0); + if (result instanceof StreamResult.Error error) { + throw LibsqlErrorMapper.toSQLException(error.error()); + } + if (!(result instanceof StreamResult.Ok ok) + || !(ok.response() instanceof StreamResponse.Execute)) { + throw new SQLException("Unexpected response: " + result); + } + } + + private static String savepointSqlName(Savepoint savepoint) throws SQLException { + if (savepoint == null) { + throw new SQLException("Savepoint cannot be null"); + } + if (savepoint instanceof LibsqlSavepoint libsqlSavepoint) { + return libsqlSavepoint.sqlName(); + } + try { + String name = savepoint.getSavepointName(); + if (name != null && !name.isBlank()) { + return name; + } + } catch (SQLException ignored) { + // An unnamed foreign savepoint has no name; its id identifies it below. + } + try { + return "SQLITE_SAVEPOINT_" + savepoint.getSavepointId(); + } catch (SQLException e) { + throw new SQLException("Savepoint has no usable name or id", e); + } + } + + private static String quoteSavepointName(String name) { + return "\"" + name.replace("\"", "\"\"") + "\""; + } + + /** Callers hold {@link #stateLock}. */ + private void clearSavepoints() { + savepointNames.clear(); + transactionStartedBySavepoint = false; } @Override diff --git a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlDatabaseMetaData.java b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlDatabaseMetaData.java index be714e2..1616c46 100644 --- a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlDatabaseMetaData.java +++ b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlDatabaseMetaData.java @@ -1012,7 +1012,7 @@ public ResultSet getTypeInfo() throws SQLException { @Override public boolean insertsAreDetected(int type) throws SQLException { return false; } @Override public boolean supportsBatchUpdates() throws SQLException { return true; } @Override public ResultSet getUDTs(String catalog, String schemaPattern, String typeNamePattern, int[] types) throws SQLException { return metadataResult(UDTS_RESULT); } - @Override public boolean supportsSavepoints() throws SQLException { return false; } + @Override public boolean supportsSavepoints() throws SQLException { return true; } @Override public boolean supportsNamedParameters() throws SQLException { return false; } @Override public boolean supportsMultipleOpenResults() throws SQLException { return false; } @Override public boolean supportsGetGeneratedKeys() throws SQLException { return true; } diff --git a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlSavepoint.java b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlSavepoint.java new file mode 100644 index 0000000..f825909 --- /dev/null +++ b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlSavepoint.java @@ -0,0 +1,43 @@ +package com.github.magnusp.libsql.jdbc; + +import java.sql.SQLException; +import java.sql.Savepoint; + +/** + * A savepoint created by {@link LibsqlConnection}. Unnamed savepoints carry a generated id and use + * {@code SQLITE_SAVEPOINT_} as their SQL name, matching xerial sqlite-jdbc; named savepoints + * carry the caller's name. Following {@code java.sql.Savepoint}, reading the id of a named + * savepoint and reading the name of an unnamed one both throw. + */ +final class LibsqlSavepoint implements Savepoint { + + private final int id; + private final String name; + private final String sqlName; + + LibsqlSavepoint(int id, String name) { + this.id = id; + this.name = name; + this.sqlName = name == null ? "SQLITE_SAVEPOINT_" + id : name; + } + + String sqlName() { + return sqlName; + } + + @Override + public int getSavepointId() throws SQLException { + if (name != null) { + throw new SQLException("Named savepoint has no id: " + name); + } + return id; + } + + @Override + public String getSavepointName() throws SQLException { + if (name == null) { + throw new SQLException("Unnamed savepoint has no name: " + id); + } + return name; + } +} diff --git a/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlSavepointTest.java b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlSavepointTest.java new file mode 100644 index 0000000..708bed5 --- /dev/null +++ b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlSavepointTest.java @@ -0,0 +1,390 @@ +package com.github.magnusp.libsql.jdbc; + +import com.github.magnusp.libsql.client.LibsqlClientConfig; +import com.github.magnusp.libsql.client.LibsqlException; +import com.github.magnusp.libsql.client.LibsqlHttpClient; +import com.github.magnusp.libsql.hrana.codec.HranaError; +import com.github.magnusp.libsql.hrana.codec.PipelineReqBody; +import com.github.magnusp.libsql.hrana.codec.PipelineRespBody; +import com.github.magnusp.libsql.hrana.codec.Stmt; +import com.github.magnusp.libsql.hrana.codec.StmtResult; +import com.github.magnusp.libsql.hrana.codec.StreamRequest; +import com.github.magnusp.libsql.hrana.codec.StreamResponse; +import com.github.magnusp.libsql.hrana.codec.StreamResult; +import org.junit.jupiter.api.Test; + +import java.net.URI; +import java.sql.SQLException; +import java.sql.SQLSyntaxErrorException; +import java.sql.Savepoint; +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 LibsqlSavepointTest { + + private static final LibsqlClientConfig CONFIG = new LibsqlClientConfig("http://localhost:1", null); + + @Test + void databaseMetaDataReportsSavepointSupport() throws SQLException { + LibsqlConnection connection = newConnection(successClient()); + + assertThat(connection.getMetaData().supportsSavepoints()).isTrue(); + } + + @Test + void unnamedSavepointInsideAnExplicitTransactionIsNestedInTheLazyBegin() throws SQLException { + ScriptedClient client = successClient(); + LibsqlConnection connection = newConnection(client); + connection.setAutoCommit(false); + + Savepoint savepoint = connection.setSavepoint(); + + assertThat(savepoint.getSavepointId()).isEqualTo(1); + assertThatThrownBy(savepoint::getSavepointName).isInstanceOf(SQLException.class); + assertThat(client.executedStatements()) + .containsExactly("BEGIN", "SAVEPOINT \"SQLITE_SAVEPOINT_1\""); + assertThat(client.requests.get(1).baton()).isNotNull(); + } + + @Test + void namedSavepointUsesTheGivenNameAndConsumesAnId() throws SQLException { + ScriptedClient client = successClient(); + LibsqlConnection connection = newConnection(client); + connection.setAutoCommit(false); + + Savepoint named = connection.setSavepoint("num7"); + Savepoint unnamed = connection.setSavepoint(); + + assertThat(named.getSavepointName()).isEqualTo("num7"); + assertThatThrownBy(named::getSavepointId).isInstanceOf(SQLException.class); + assertThat(unnamed.getSavepointId()).isEqualTo(2); + assertThat(client.executedStatements()) + .containsExactly("BEGIN", "SAVEPOINT \"num7\"", "SAVEPOINT \"SQLITE_SAVEPOINT_2\""); + } + + @Test + void savepointNamesAreQuotedAsIdentifiers() throws SQLException { + ScriptedClient client = successClient(); + LibsqlConnection connection = newConnection(client); + connection.setAutoCommit(false); + + connection.setSavepoint("weird \" name"); + + assertThat(client.executedStatements()) + .containsExactly("BEGIN", "SAVEPOINT \"weird \"\" name\""); + } + + @Test + void setSavepointRejectsNullAndBlankNamesWithoutSendingAnything() { + ScriptedClient client = successClient(); + LibsqlConnection connection = newConnection(client); + + assertThatThrownBy(() -> connection.setSavepoint(null)).isInstanceOf(SQLException.class); + assertThatThrownBy(() -> connection.setSavepoint(" ")).isInstanceOf(SQLException.class); + assertThat(client.requests).isEmpty(); + } + + @Test + void savepointInAutoCommitStartsATransactionWithoutABegin() throws SQLException { + ScriptedClient client = successClient(); + LibsqlConnection connection = newConnection(client); + + connection.setSavepoint(); + connection.executeStatement(new Stmt("INSERT INTO sample VALUES (1)")); + connection.commit(); + + assertThat(connection.getAutoCommit()).isFalse(); + assertThat(client.executedStatements()) + .containsExactly( + "SAVEPOINT \"SQLITE_SAVEPOINT_1\"", + "INSERT INTO sample VALUES (1)", + "COMMIT"); + assertThat(client.requests).hasSize(4); + assertThat(client.requests.get(3).requests()).containsExactly(StreamRequest.Close.INSTANCE); + } + + @Test + void rollbackToSavepointKeepsTheSavepointAndTheStream() throws SQLException { + ScriptedClient client = successClient(); + LibsqlConnection connection = newConnection(client); + connection.setAutoCommit(false); + connection.executeStatement(new Stmt("INSERT INTO sample VALUES (1)")); + Savepoint savepoint = connection.setSavepoint("sp1"); + connection.executeStatement(new Stmt("INSERT INTO sample VALUES (2)")); + + connection.rollback(savepoint); + connection.rollback(savepoint); + + assertThat(client.executedStatements()) + .containsExactly( + "BEGIN", + "INSERT INTO sample VALUES (1)", + "SAVEPOINT \"sp1\"", + "INSERT INTO sample VALUES (2)", + "ROLLBACK TO SAVEPOINT \"sp1\"", + "ROLLBACK TO SAVEPOINT \"sp1\""); + assertThat(client.requests.stream() + .flatMap(request -> request.requests().stream()) + .filter(StreamRequest.Close.class::isInstance)) + .isEmpty(); + } + + @Test + void releasingANestedSavepointKeepsTheTransactionStream() throws SQLException { + ScriptedClient client = successClient(); + LibsqlConnection connection = newConnection(client); + connection.setAutoCommit(false); + connection.executeStatement(new Stmt("INSERT INTO sample VALUES (1)")); + Savepoint outer = connection.setSavepoint("outer_sp"); + Savepoint inner = connection.setSavepoint("inner_sp"); + + connection.releaseSavepoint(inner); + connection.executeStatement(new Stmt("INSERT INTO sample VALUES (2)")); + + assertThat(client.executedStatements()) + .containsExactly( + "BEGIN", + "INSERT INTO sample VALUES (1)", + "SAVEPOINT \"outer_sp\"", + "SAVEPOINT \"inner_sp\"", + "RELEASE SAVEPOINT \"inner_sp\"", + "INSERT INTO sample VALUES (2)"); + assertThat(client.requests.stream() + .flatMap(request -> request.requests().stream()) + .filter(StreamRequest.Close.class::isInstance)) + .isEmpty(); + assertThat(outer.getSavepointName()).isEqualTo("outer_sp"); + } + + @Test + void releasingTheSavepointThatStartedTheTransactionCommitsAndClosesTheStream() + throws SQLException { + ScriptedClient client = successClient(); + LibsqlConnection connection = newConnection(client); + Savepoint outer = connection.setSavepoint("outer_sp"); + Savepoint inner = connection.setSavepoint("inner_sp"); + + connection.releaseSavepoint(inner); + connection.releaseSavepoint(outer); + connection.executeStatement(new Stmt("SELECT 1")); + + assertThat(connection.getAutoCommit()).isFalse(); + assertThat(client.executedStatements()) + .containsExactly( + "SAVEPOINT \"outer_sp\"", + "SAVEPOINT \"inner_sp\"", + "RELEASE SAVEPOINT \"inner_sp\"", + "RELEASE SAVEPOINT \"outer_sp\"", + "BEGIN", + "SELECT 1"); + assertThat(client.requests.stream() + .flatMap(request -> request.requests().stream()) + .filter(StreamRequest.Close.class::isInstance)) + .hasSize(1); + } + + @Test + void releasingTheOutermostSavepointInsideAnExplicitBeginKeepsTheTransaction() + throws SQLException { + ScriptedClient client = successClient(); + LibsqlConnection connection = newConnection(client); + connection.setAutoCommit(false); + connection.executeStatement(new Stmt("INSERT INTO sample VALUES (1)")); + Savepoint savepoint = connection.setSavepoint("sp1"); + + connection.releaseSavepoint(savepoint); + connection.executeStatement(new Stmt("INSERT INTO sample VALUES (2)")); + + assertThat(client.executedStatements()) + .containsExactly( + "BEGIN", + "INSERT INTO sample VALUES (1)", + "SAVEPOINT \"sp1\"", + "RELEASE SAVEPOINT \"sp1\"", + "INSERT INTO sample VALUES (2)"); + } + + @Test + void rollbackAndReleaseThrowInAutoCommitModeWithoutSendingAnything() throws SQLException { + ScriptedClient client = successClient(); + LibsqlConnection connection = newConnection(client); + connection.setAutoCommit(false); + Savepoint savepoint = connection.setSavepoint("sp1"); + connection.setAutoCommit(true); + int requestsBefore = client.requests.size(); + + assertThatThrownBy(() -> connection.rollback(savepoint)) + .isInstanceOf(SQLException.class) + .hasMessageContaining("autoCommit"); + assertThatThrownBy(() -> connection.releaseSavepoint(savepoint)) + .isInstanceOf(SQLException.class) + .hasMessageContaining("autoCommit"); + assertThat(client.requests).hasSize(requestsBefore); + } + + @Test + void rollbackAndReleaseRejectANullSavepoint() throws SQLException { + LibsqlConnection connection = newConnection(successClient()); + connection.setAutoCommit(false); + + assertThatThrownBy(() -> connection.rollback(null)).isInstanceOf(SQLException.class); + assertThatThrownBy(() -> connection.releaseSavepoint(null)).isInstanceOf(SQLException.class); + } + + @Test + void savepointMethodsThrowOnAClosedConnection() throws SQLException { + LibsqlConnection connection = newConnection(successClient()); + connection.setAutoCommit(false); + Savepoint savepoint = connection.setSavepoint("sp1"); + connection.close(); + + assertThatThrownBy(connection::setSavepoint).isInstanceOf(SQLException.class); + assertThatThrownBy(() -> connection.setSavepoint("sp2")).isInstanceOf(SQLException.class); + assertThatThrownBy(() -> connection.rollback(savepoint)).isInstanceOf(SQLException.class); + assertThatThrownBy(() -> connection.releaseSavepoint(savepoint)) + .isInstanceOf(SQLException.class); + } + + @Test + void aServerErrorOnRollbackToSavepointIsMappedAndTheTransactionStaysOpen() throws SQLException { + ScriptedClient client = new ScriptedClient((request, index) -> { + String sql = executedSql(request); + if (sql != null && sql.startsWith("ROLLBACK TO SAVEPOINT")) { + return errorResponse(new HranaError("no such savepoint: sp1", "SQLITE_ERROR")); + } + return successResponse(request); + }); + LibsqlConnection connection = newConnection(client); + connection.setAutoCommit(false); + connection.executeStatement(new Stmt("INSERT INTO sample VALUES (1)")); + Savepoint savepoint = connection.setSavepoint("sp1"); + + assertThatThrownBy(() -> connection.rollback(savepoint)) + .isInstanceOf(SQLSyntaxErrorException.class) + .hasMessageContaining("no such savepoint") + .satisfies(thrown -> assertThat(((SQLException) thrown).getSQLState()).isEqualTo("42000")); + + connection.executeStatement(new Stmt("SELECT 1")); + + assertThat(client.executedStatements()) + .containsExactly( + "BEGIN", + "INSERT INTO sample VALUES (1)", + "SAVEPOINT \"sp1\"", + "ROLLBACK TO SAVEPOINT \"sp1\"", + "SELECT 1"); + } + + @Test + void aFailedImplicitSavepointLeavesAutoCommitOnAndClosesTheFreshStream() throws SQLException { + ScriptedClient client = new ScriptedClient((request, index) -> { + String sql = executedSql(request); + if (sql != null && sql.startsWith("SAVEPOINT")) { + return errorResponse(new HranaError("savepoint failed", "SQLITE_ERROR")); + } + return successResponse(request); + }); + LibsqlConnection connection = newConnection(client); + + assertThatThrownBy(connection::setSavepoint) + .isInstanceOf(SQLException.class) + .hasMessageContaining("savepoint failed"); + assertThat(connection.getAutoCommit()).isTrue(); + assertThat(client.executedStatements()).containsExactly("SAVEPOINT \"SQLITE_SAVEPOINT_1\""); + assertThat(client.requests).hasSize(2); + assertThat(client.requests.get(1).requests()).containsExactly(StreamRequest.Close.INSTANCE); + } + + private static LibsqlConnection newConnection(ScriptedClient client) { + return new LibsqlConnection(CONFIG, client); + } + + private static ScriptedClient successClient() { + return new ScriptedClient((request, index) -> successResponse(request)); + } + + private static PipelineRespBody successResponse(PipelineReqBody request) { + List results = new ArrayList<>(); + for (StreamRequest streamRequest : request.requests()) { + if (streamRequest instanceof StreamRequest.Close) { + results.add(new StreamResult.Ok(StreamResponse.Close.INSTANCE)); + } else { + results.add(new StreamResult.Ok( + new StreamResponse.Execute(new StmtResult(List.of(), List.of(), 0, null)))); + } + } + return new PipelineRespBody(null, null, results); + } + + private static PipelineRespBody errorResponse(HranaError error) { + return new PipelineRespBody(null, null, List.of(new StreamResult.Error(error))); + } + + private static String executedSql(PipelineReqBody request) { + if (request.requests().isEmpty() + || !(request.requests().get(0) instanceof StreamRequest.Execute execute)) { + return null; + } + return execute.stmt().sql(); + } + + private static final class ScriptedClient extends LibsqlHttpClient { + + @FunctionalInterface + private interface Handler { + PipelineRespBody handle(PipelineReqBody request, int index); + } + + private final Handler handler; + private final List requests = new ArrayList<>(); + private String issuedBaton = null; + private int batonCount = 0; + + private ScriptedClient(Handler handler) { + super(CONFIG); + this.handler = handler; + } + + @Override + public StmtResult executeOneShot(Stmt stmt) { + PipelineReqBody request = new PipelineReqBody(null, List.of( + new StreamRequest.Execute(stmt), + StreamRequest.Close.INSTANCE)); + requests.add(request); + PipelineRespBody response = handler.handle(request, requests.size() - 1); + StreamResult first = response.results().get(0); + return switch (first) { + case StreamResult.Ok ok -> { + if (ok.response() instanceof StreamResponse.Execute exec) { + yield exec.result(); + } + throw new LibsqlException("Unexpected stream response: " + ok.response(), null, 200); + } + case StreamResult.Error err -> throw new LibsqlException(err.error()); + }; + } + + @Override + public PipelineRespBody sendPipeline(PipelineReqBody request, URI targetUri) { + requests.add(request); + if (request.baton() != null && !request.baton().equals(issuedBaton)) { + throw new LibsqlException("stale baton " + request.baton(), "STALE_BATON", 400); + } + PipelineRespBody response = handler.handle(request, requests.size() - 1); + issuedBaton = "baton-" + (++batonCount); + return new PipelineRespBody(issuedBaton, response.baseUrl(), response.results()); + } + + private List executedStatements() { + return requests.stream() + .flatMap(request -> request.requests().stream()) + .filter(StreamRequest.Execute.class::isInstance) + .map(StreamRequest.Execute.class::cast) + .map(execute -> execute.stmt().sql()) + .toList(); + } + } +} diff --git a/upstream-xerial_sqlite-jdbc.manifest b/upstream-xerial_sqlite-jdbc.manifest index 94ae1e9..531beb8 100644 --- a/upstream-xerial_sqlite-jdbc.manifest +++ b/upstream-xerial_sqlite-jdbc.manifest @@ -30,6 +30,7 @@ # selected: src/test/java/org/sqlite/DBMetaDataTest.java -- DatabaseMetaData tables, columns, keys, type info, and java.sql-shaped empty result sets. # selected: src/test/java/org/sqlite/StatementTest.java -- Statement lifecycle, conversions, batches, and execute transitions over ordinary SQL. # selected: src/test/java/org/sqlite/PrepStmtTest.java -- PreparedStatement binding, metadata, batches, and value round trips. +# selected: src/test/java/org/sqlite/SavepointTest.java -- savepoint creation, rollback, and release over ordinary SQL. # Rejected tests. # rejected: src/test/java/org/sqlite/BackupTest.java -- drives sqlite-jdbc's native backup API; Hrana exposes no backup primitive. @@ -46,7 +47,6 @@ # rejected: src/test/java/org/sqlite/ProgressHandlerTest.java -- registers native progress callbacks that Hrana does not expose. # rejected: src/test/java/org/sqlite/QueryTest.java -- imports sqlite-jdbc's SQLiteConfig and FastDateFormat classes and drives query timeouts; the conversion getters it exercises are covered by selected ResultSetTest cases. # rejected: src/test/java/org/sqlite/ReadUncommittedTest.java -- depends on sqlite-jdbc shared-cache and read-uncommitted connection configuration; sqld does not expose it. -# rejected: src/test/java/org/sqlite/SavepointTest.java -- savepoints are not implemented by the libSQL driver. # rejected: src/test/java/org/sqlite/SerializeTest.java -- serializes and restores a native database image; Hrana has no equivalent. # rejected: src/test/java/org/sqlite/SQLiteConfigTest.java -- tests sqlite-jdbc's configuration object, not the JDBC API. # rejected: src/test/java/org/sqlite/SQLiteConnectionPoolDataSourceTest.java -- tests sqlite-jdbc DataSource and pooling classes that libSQL does not provide. @@ -79,6 +79,9 @@ src/test/java/org/sqlite/DBMetaDataTest.java src/test/java/org/sqlite/StatementTest.java src/test/java/org/sqlite/PrepStmtTest.java +# Fourth batch: savepoints. +src/test/java/org/sqlite/SavepointTest.java + LICENSE LICENSE.zentus NOTICE diff --git a/upstream-xerial_sqlite-jdbc/CHECKSUMS.sha256 b/upstream-xerial_sqlite-jdbc/CHECKSUMS.sha256 index 233995e..4b2e9e4 100644 --- a/upstream-xerial_sqlite-jdbc/CHECKSUMS.sha256 +++ b/upstream-xerial_sqlite-jdbc/CHECKSUMS.sha256 @@ -32,6 +32,8 @@ d787e71bdf2b8d12a2669eb06c432cf2a39ac77f678a92459e3dd9b6c4519a43 ./src/test/jav 6065d284933c8462cc13217992820a9261075c0e9ee126df6cbe9961d90303f9 ./src/test/java/org/sqlite/ResultSetTest.java # upstream 958c1f2ff5ca2f1257dfd847207ea793839969b234d140576b51af346697233c ./src/test/java/org/sqlite/ResultSetWithoutResultsTest.java 958c1f2ff5ca2f1257dfd847207ea793839969b234d140576b51af346697233c ./src/test/java/org/sqlite/ResultSetWithoutResultsTest.java +# upstream 0391680df392315cc6ba7a6b91299aa42f80da0a715880696e64adf41dd45daf ./src/test/java/org/sqlite/SavepointTest.java +0391680df392315cc6ba7a6b91299aa42f80da0a715880696e64adf41dd45daf ./src/test/java/org/sqlite/SavepointTest.java # upstream 81125078aa972d55deba801a81841271a659f0f3306060e128850cbe8cc4054b ./src/test/java/org/sqlite/StatementTest.java 8328eec31b4504df729d39e18c978e03d71c455080a17e943e61d30ff926330c ./src/test/java/org/sqlite/StatementTest.java # upstream bef3eddca2a6b80472050a3a52d5e7246128f0644d29c24ed091709b0ed6f121 ./src/test/java/org/sqlite/Utils.java diff --git a/upstream-xerial_sqlite-jdbc/UPSTREAM.md b/upstream-xerial_sqlite-jdbc/UPSTREAM.md index b887e8d..fa2bb85 100644 --- a/upstream-xerial_sqlite-jdbc/UPSTREAM.md +++ b/upstream-xerial_sqlite-jdbc/UPSTREAM.md @@ -9,7 +9,7 @@ | Commit date | 2026-08-26T09:55:34Z | | Commit author | github-actions <41898282+github-actions[bot]@users.noreply.github.com> | | Commit subject | chore(release): 3.53.4.0 [skip ci] | -| Paths | src/test/java/org/sqlite/Utils.java, src/test/java/org/sqlite/ResultSetTest.java, src/test/java/org/sqlite/InsertQueryTest.java, src/test/java/org/sqlite/FetchSizeTest.java, src/test/java/org/sqlite/CachedRowSetTest.java, src/test/java/org/sqlite/JSON1Test.java, src/test/java/org/sqlite/MetadataLeakTest.java, src/test/java/org/sqlite/PreparedStatementThreadTest.java, src/test/java/org/sqlite/ResultSetWithoutResultsTest.java, src/test/java/org/sqlite/RSMetaDataTest.java, src/test/java/org/sqlite/DBMetaDataTest.java, src/test/java/org/sqlite/StatementTest.java, src/test/java/org/sqlite/PrepStmtTest.java, LICENSE, LICENSE.zentus, NOTICE, VERSION | +| Paths | src/test/java/org/sqlite/Utils.java, src/test/java/org/sqlite/ResultSetTest.java, src/test/java/org/sqlite/InsertQueryTest.java, src/test/java/org/sqlite/FetchSizeTest.java, src/test/java/org/sqlite/CachedRowSetTest.java, src/test/java/org/sqlite/JSON1Test.java, src/test/java/org/sqlite/MetadataLeakTest.java, src/test/java/org/sqlite/PreparedStatementThreadTest.java, src/test/java/org/sqlite/ResultSetWithoutResultsTest.java, src/test/java/org/sqlite/RSMetaDataTest.java, src/test/java/org/sqlite/DBMetaDataTest.java, src/test/java/org/sqlite/StatementTest.java, src/test/java/org/sqlite/PrepStmtTest.java, src/test/java/org/sqlite/SavepointTest.java, LICENSE, LICENSE.zentus, NOTICE, VERSION | | License | Apache-2.0 and Zentus BSD. See `LICENSE`, `LICENSE.zentus`, and `NOTICE`. | The exported upstream files are byte-identical copies at this commit and must not be edited. `export-upstream` regenerates this tree. diff --git a/upstream-xerial_sqlite-jdbc/src/test/java/org/sqlite/SavepointTest.java b/upstream-xerial_sqlite-jdbc/src/test/java/org/sqlite/SavepointTest.java new file mode 100644 index 0000000..22c5ac2 --- /dev/null +++ b/upstream-xerial_sqlite-jdbc/src/test/java/org/sqlite/SavepointTest.java @@ -0,0 +1,202 @@ +package org.sqlite; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.File; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Savepoint; +import java.sql.Statement; +import java.util.Properties; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * These tests assume that Statements and PreparedStatements are working as per normal and test the + * interactions of commit(), setSavepoint(), setSavepoint(String), rollback(Savepoint), and + * release(Savepoint). + */ +public class SavepointTest { + private Connection conn1, conn2; + private Statement stat1, stat2; + + @BeforeEach + public void connect(@TempDir File tempDir) throws Exception { + File tmpFile = File.createTempFile("test-trans", ".db", tempDir); + + Properties prop = new Properties(); + prop.setProperty("shared_cache", "false"); + + conn1 = DriverManager.getConnection("jdbc:sqlite:" + tmpFile.getAbsolutePath(), prop); + conn2 = DriverManager.getConnection("jdbc:sqlite:" + tmpFile.getAbsolutePath(), prop); + + stat1 = conn1.createStatement(); + stat2 = conn2.createStatement(); + } + + @AfterEach + public void close() throws Exception { + stat1.close(); + stat2.close(); + conn1.close(); + conn2.close(); + } + + @Test + public void insert() throws SQLException { + ResultSet rs; + String countSql = "select count(*) from trans;"; + + stat1.executeUpdate("create table trans (c1);"); + conn1.setSavepoint(); + + assertThat(stat1.executeUpdate("insert into trans values (4);")).isEqualTo(1); + + // transaction not yet committed, conn1 can see, conn2 can not + rs = stat1.executeQuery(countSql); + assertThat(rs.next()).isTrue(); + assertThat(rs.getInt(1)).isEqualTo(1); + rs.close(); + rs = stat2.executeQuery(countSql); + assertThat(rs.next()).isTrue(); + assertThat(rs.getInt(1)).isEqualTo(0); + rs.close(); + + conn1.commit(); + + // all connects can see data + rs = stat2.executeQuery(countSql); + assertThat(rs.next()).isTrue(); + assertThat(rs.getInt(1)).isEqualTo(1); + rs.close(); + } + + @Test + public void rollback() throws SQLException { + String select = "select * from trans;"; + ResultSet rs; + + stat1.executeUpdate("create table trans (c1);"); + Savepoint sp = conn1.setSavepoint(); + stat1.executeUpdate("insert into trans values (3);"); + + rs = stat1.executeQuery(select); + assertThat(rs.next()).isTrue(); + rs.close(); + + conn1.rollback(sp); + + rs = stat1.executeQuery(select); + assertThat(rs.next()).isFalse(); + rs.close(); + } + + @Test + public void multiRollback() throws SQLException { + ResultSet rs; + + stat1.executeUpdate("create table t (c1);"); + conn1.setSavepoint(); + stat1.executeUpdate("insert into t values (1);"); + conn1.commit(); + + Savepoint sp = conn1.setSavepoint(); + stat1.executeUpdate("insert into t values (1);"); + conn1.rollback(sp); + + stat1.addBatch("insert into t values (2);"); + stat1.addBatch("insert into t values (3);"); + stat1.executeBatch(); + conn1.commit(); + + Savepoint sp7 = conn1.setSavepoint("num7"); + stat1.addBatch("insert into t values (7);"); + stat1.executeBatch(); + + // nested savepoint + Savepoint sp8 = conn1.setSavepoint("num8"); + stat1.addBatch("insert into t values (8);"); + stat1.executeBatch(); + conn1.rollback(sp8); + + conn1.rollback(sp7); + + stat1.executeUpdate("insert into t values (4);"); + + conn1.setAutoCommit(true); + stat1.executeUpdate("insert into t values (5);"); + conn1.setAutoCommit(false); + PreparedStatement p = conn1.prepareStatement("insert into t values (?);"); + p.setInt(1, 6); + p.executeUpdate(); + p.setInt(1, 7); + p.executeUpdate(); + + // conn1 can see (1+...+7), conn2 can see (1+...+5) + rs = stat1.executeQuery("select sum(c1) from t;"); + assertThat(rs.next()).isTrue(); + assertThat(rs.getInt(1)).isEqualTo(1 + 2 + 3 + 4 + 5 + 6 + 7); + rs.close(); + + rs = stat2.executeQuery("select sum(c1) from t;"); + assertThat(rs.next()).isTrue(); + assertThat(rs.getInt(1)).isEqualTo(1 + 2 + 3 + 4 + 5); + rs.close(); + } + + @Test + public void release() throws SQLException { + ResultSet rs; + String countSql = "select count(*) from trans;"; + + stat1.executeUpdate("create table trans (c1);"); + + Savepoint outerSP = conn1.setSavepoint("outer_sp"); + assertThat(stat1.executeUpdate("insert into trans values (4);")).isEqualTo(1); + + // transaction not yet commited, conn1 can see, conn2 can not + rs = stat1.executeQuery(countSql); + assertThat(rs.next()).isTrue(); + assertThat(rs.getInt(1)).isEqualTo(1); + rs.close(); + rs = stat2.executeQuery(countSql); + assertThat(rs.next()).isTrue(); + assertThat(rs.getInt(1)).isEqualTo(0); + rs.close(); + + Savepoint innerSP = conn1.setSavepoint("inner_sp"); + assertThat(stat1.executeUpdate("insert into trans values (5);")).isEqualTo(1); + + // transaction not yet commited, conn1 can see, conn2 can not + rs = stat1.executeQuery(countSql); + assertThat(rs.next()).isTrue(); + assertThat(rs.getInt(1)).isEqualTo(2); + rs.close(); + rs = stat2.executeQuery(countSql); + assertThat(rs.next()).isTrue(); + assertThat(rs.getInt(1)).isEqualTo(0); + rs.close(); + + // releasing an inner savepoint, statements are still wrapped by the outer savepoint + conn1.releaseSavepoint(innerSP); + + rs = stat2.executeQuery(countSql); + assertThat(rs.next()).isTrue(); + assertThat(rs.getInt(1)).isEqualTo(0); + rs.close(); + + // releasing the outer savepoint is like a commit + conn1.releaseSavepoint(outerSP); + + // all connects can see SP1 data + rs = stat2.executeQuery(countSql); + assertThat(rs.next()).isTrue(); + assertThat(rs.getInt(1)).isEqualTo(2); + rs.close(); + } +}