From 2612b9e10a4ae23b673c9ef56e102e5d0ad3410d Mon Sep 17 00:00:00 2001 From: Magnus Persson <1431685+magnusp@users.noreply.github.com> Date: Fri, 23 Oct 2026 03:45:45 +0200 Subject: [PATCH] fix(jdbc): map in-transaction Hrana errors through the error mapper --- .../libsql/itest/TransactionLifecycleIT.java | 26 ++++ .../magnusp/libsql/jdbc/LibsqlConnection.java | 17 ++- .../libsql/jdbc/LibsqlErrorMapper.java | 62 +++++--- .../libsql/jdbc/LibsqlConnectionTest.java | 133 ++++++++++++++++++ 4 files changed, 214 insertions(+), 24 deletions(-) diff --git a/libsql-itests/src/test/java/com/github/magnusp/libsql/itest/TransactionLifecycleIT.java b/libsql-itests/src/test/java/com/github/magnusp/libsql/itest/TransactionLifecycleIT.java index 04b205e..f430fdc 100644 --- a/libsql-itests/src/test/java/com/github/magnusp/libsql/itest/TransactionLifecycleIT.java +++ b/libsql-itests/src/test/java/com/github/magnusp/libsql/itest/TransactionLifecycleIT.java @@ -1,12 +1,14 @@ package com.github.magnusp.libsql.itest; import com.github.magnusp.libsql.jdbc.LibsqlConnection; +import org.assertj.core.api.ThrowableAssert; import org.junit.jupiter.api.Test; import java.sql.Connection; import java.sql.DriverManager; import java.sql.ResultSet; import java.sql.SQLException; +import java.sql.SQLIntegrityConstraintViolationException; import java.sql.Statement; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -170,6 +172,30 @@ void failedCommitCanBeRolledBackAndTheConnectionStaysUsable() throws Exception { } } + @Test + void uniqueViolationKeepsItsMappedSqlStateInBothModes() throws Exception { + String url = getDriverJdbcUrl(); + try (Connection connection = DriverManager.getConnection(url)) { + try (Statement statement = connection.createStatement()) { + statement.execute("CREATE TABLE unique_state (id INTEGER PRIMARY KEY)"); + statement.executeUpdate("INSERT INTO unique_state (id) VALUES (1)"); + } + + assertMappedUniqueViolation(() -> insertRow(connection, "unique_state", 1)); + + connection.setAutoCommit(false); + assertMappedUniqueViolation(() -> insertRow(connection, "unique_state", 1)); + connection.rollback(); + } + } + + private static void assertMappedUniqueViolation(ThrowableAssert.ThrowingCallable call) { + assertThatThrownBy(call) + .isInstanceOf(SQLIntegrityConstraintViolationException.class) + .hasMessageContaining("UNIQUE constraint failed") + .satisfies(thrown -> assertThat(((SQLException) thrown).getSQLState()).isEqualTo("23000")); + } + private static void assertWriteIsBlockedUntilReleased( String url, Connection lockHolder, String table, int id) throws Exception { ExecutorService executor = Executors.newSingleThreadExecutor(); 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 016113d..06467e6 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 @@ -122,7 +122,7 @@ public StmtResult executeStatement(Stmt stmt) throws SQLException { } throw new SQLException("Unexpected response: " + ok.response()); } - case StreamResult.Error err -> throw new SQLException(err.error().message()); + case StreamResult.Error err -> throw LibsqlErrorMapper.toSQLException(err.error()); }; } } catch (LibsqlException e) { @@ -185,7 +185,7 @@ private List extractBatchResults(PipelineRespBody resp, int expected } StreamResult sr = resp.results().get(0); if (sr instanceof StreamResult.Error err) { - throw new SQLException(err.error().message()); + throw LibsqlErrorMapper.toSQLException(err.error()); } StreamResponse response = ((StreamResult.Ok) sr).response(); if (!(response instanceof StreamResponse.BatchResp batchResp)) { @@ -197,7 +197,14 @@ private List extractBatchResults(PipelineRespBody resp, int expected HranaError error = batchResp.result().stepErrors().get(i); if (error != null) { // JDBC stops the batch at the first failure and reports only the steps that ran. - throw new BatchUpdateException(error.message(), steppedUpdateCounts(results)); + // The mapped exception is the cause, so SQLState and vendor code survive too. + SQLException mapped = LibsqlErrorMapper.toSQLException(error); + throw new BatchUpdateException( + error.message(), + mapped.getSQLState(), + mapped.getErrorCode(), + steppedUpdateCounts(results), + mapped); } StmtResult result = batchResp.result().stepResults().get(i); if (result == null) { @@ -251,7 +258,7 @@ private void beginTransaction() throws SQLException { StreamResult result = resp.results().get(0); if (result instanceof StreamResult.Error error) { closeStreamSilently(); - throw new SQLException(error.error().message()); + throw LibsqlErrorMapper.toSQLException(error.error()); } if (!(result instanceof StreamResult.Ok ok) || !(ok.response() instanceof StreamResponse.Execute)) { @@ -382,7 +389,7 @@ private void endTransaction(String sql) throws SQLException { } StreamResult result = resp.results().get(0); if (result instanceof StreamResult.Error error) { - throw new SQLException(error.error().message()); + throw LibsqlErrorMapper.toSQLException(error.error()); } if (!(result instanceof StreamResult.Ok ok) || !(ok.response() instanceof StreamResponse.Execute)) { diff --git a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlErrorMapper.java b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlErrorMapper.java index 973af11..37dcc30 100644 --- a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlErrorMapper.java +++ b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlErrorMapper.java @@ -1,46 +1,70 @@ package com.github.magnusp.libsql.jdbc; import com.github.magnusp.libsql.client.LibsqlException; +import com.github.magnusp.libsql.hrana.codec.HranaError; -import java.sql.SQLClientInfoException; import java.sql.SQLException; -import java.sql.SQLFeatureNotSupportedException; import java.sql.SQLIntegrityConstraintViolationException; import java.sql.SQLInvalidAuthorizationSpecException; -import java.sql.SQLNonTransientConnectionException; import java.sql.SQLSyntaxErrorException; -import java.sql.SQLTimeoutException; import java.sql.SQLTransientConnectionException; +import java.util.Locale; public class LibsqlErrorMapper { public static SQLException toSQLException(LibsqlException e) { - String code = e.getCode(); - String message = e.getMessage() != null ? e.getMessage() : "Unknown libSQL error"; + return toSQLException(e.getCode(), e.getMessage(), e.getHttpStatus(), e); + } + + /** + * Maps a Hrana {@code Error} returned inside a stream, batch, or transaction. The protocol + * leaves {@code code} unstable, so an unrecognized code falls back to categorizing the message + * rather than losing the constraint classification. + */ + public static SQLException toSQLException(HranaError e) { + return toSQLException(e.code(), e.message(), 200, new LibsqlException(e)); + } + private static SQLException toSQLException( + String code, String message, int httpStatus, Throwable cause) { + message = message != null ? message : "Unknown libSQL error"; + + if (isConstraintViolation(code, message)) { + return new SQLIntegrityConstraintViolationException(message, "23000", 19, cause); + } if (code != null) { - String upper = code.toUpperCase(); - if (upper.contains("CONSTRAINT") || upper.contains("SQLITE_CONSTRAINT")) { - return new SQLIntegrityConstraintViolationException(message, "23000", 19, e); - } - if (upper.contains("BUSY") || upper.contains("SQLITE_BUSY") || upper.contains("LOCKED")) { - return new SQLTransientConnectionException(message, "08001", 5, e); + String upper = code.toUpperCase(Locale.ROOT); + if (upper.contains("BUSY") || upper.contains("LOCKED")) { + return new SQLTransientConnectionException(message, "08001", 5, cause); } if (upper.contains("SYNTAX") || upper.contains("SQLITE_ERROR")) { - return new SQLSyntaxErrorException(message, "42000", 1, e); + return new SQLSyntaxErrorException(message, "42000", 1, cause); } if (upper.contains("AUTH") || upper.contains("JWT")) { - return new SQLInvalidAuthorizationSpecException(message, "28000", e); + return new SQLInvalidAuthorizationSpecException(message, "28000", cause); } } - if (e.getHttpStatus() == 401 || e.getHttpStatus() == 403) { - return new SQLInvalidAuthorizationSpecException(message, "28000", e); + if (httpStatus == 401 || httpStatus == 403) { + return new SQLInvalidAuthorizationSpecException(message, "28000", cause); } - if (e.getHttpStatus() == 429 || e.getHttpStatus() == 503) { - return new SQLTransientConnectionException(message, "08001", e); + if (httpStatus == 429 || httpStatus == 503) { + return new SQLTransientConnectionException(message, "08001", cause); } - return new SQLException(message, "HY000", e); + return new SQLException(message, "HY000", cause); + } + + /** + * A stable category the protocol does not promise: the server may report {@code + * SQLITE_CONSTRAINT_UNIQUE}, the older {@code SQLITE_CONSTRAINT}, or no code at all, so the + * SQLite message text is the fallback signal. + */ + private static boolean isConstraintViolation(String code, String message) { + if (code != null && code.toUpperCase(Locale.ROOT).contains("CONSTRAINT")) { + return true; + } + String upper = message.toUpperCase(Locale.ROOT); + return upper.contains("CONSTRAINT FAILED") || upper.contains("UNIQUE CONSTRAINT"); } } diff --git a/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlConnectionTest.java b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlConnectionTest.java index 4f76465..d2862dd 100644 --- a/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlConnectionTest.java +++ b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlConnectionTest.java @@ -23,6 +23,8 @@ import java.net.URI; import java.sql.BatchUpdateException; import java.sql.SQLException; +import java.sql.SQLIntegrityConstraintViolationException; +import java.sql.SQLTransientConnectionException; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -39,6 +41,7 @@ class LibsqlConnectionTest { private static final String SERVER_ERROR = "transaction end failed on server"; private static final String TRANSPORT_ERROR = "Connection reset by peer"; private static final String BATCH_FAILURE = "UNIQUE constraint failed: sample.id"; + private static final String UNIQUE_VIOLATION = "UNIQUE constraint failed: sample.id"; @Test void getMetaDataIsCachedPerConnection() throws SQLException { @@ -435,6 +438,112 @@ void batchStepWithNeitherResultNorErrorIsAProtocolError() throws SQLException { .hasMessageContaining("neither a result nor an error"); } + @ParameterizedTest(name = "autoCommit={0}") + @ValueSource(booleans = {true, false}) + void uniqueViolationMapsToTheSameExceptionTypeAndSqlStateInBothModes(boolean autoCommit) + throws SQLException { + ScriptedClient client = new ScriptedClient((request, index) -> { + if ("INSERT INTO sample VALUES (1)".equals(executedSql(request))) { + return errorResponse(new HranaError(UNIQUE_VIOLATION, "SQLITE_CONSTRAINT_UNIQUE")); + } + return successResponse(request); + }); + LibsqlConnection connection = new LibsqlConnection(CONFIG, client); + connection.setAutoCommit(autoCommit); + + assertThatThrownBy(() -> connection.executeStatement(new Stmt("INSERT INTO sample VALUES (1)"))) + .isInstanceOf(SQLIntegrityConstraintViolationException.class) + .hasMessageContaining("UNIQUE constraint failed") + .satisfies(thrown -> { + SQLException mapped = (SQLException) thrown; + assertThat(mapped.getSQLState()).isEqualTo("23000"); + assertThat(mapped.getErrorCode()).isEqualTo(19); + }); + } + + @Test + void failureToBeginTheTransactionIsMapped() throws SQLException { + ScriptedClient client = new ScriptedClient((request, index) -> { + if ("BEGIN".equals(executedSql(request))) { + return errorResponse(new HranaError("database is locked", "SQLITE_BUSY")); + } + return successResponse(request); + }); + LibsqlConnection connection = new LibsqlConnection(CONFIG, client); + connection.setAutoCommit(false); + + assertThatThrownBy(() -> connection.executeStatement(new Stmt("SELECT 1"))) + .isInstanceOf(SQLTransientConnectionException.class) + .hasMessageContaining("database is locked") + .satisfies(thrown -> assertThat(((SQLException) thrown).getSQLState()).isEqualTo("08001")); + } + + @Test + void aStatementErrorInsideTheTransactionIsMapped() throws SQLException { + ScriptedClient client = new ScriptedClient((request, index) -> { + if ("INSERT INTO sample VALUES (2)".equals(executedSql(request))) { + return errorResponse(new HranaError(UNIQUE_VIOLATION, "SQLITE_CONSTRAINT_UNIQUE")); + } + return successResponse(request); + }); + LibsqlConnection connection = connectionInTransaction(client); + + assertThatThrownBy(() -> connection.executeStatement(new Stmt("INSERT INTO sample VALUES (2)"))) + .isInstanceOf(SQLIntegrityConstraintViolationException.class) + .satisfies(thrown -> assertThat(((SQLException) thrown).getSQLState()).isEqualTo("23000")); + } + + @ParameterizedTest(name = "{0}") + @ValueSource(strings = {"COMMIT", "ROLLBACK"}) + void aTransactionEndErrorIsMapped(String command) throws SQLException { + ScriptedClient client = new ScriptedClient((request, index) -> { + if (command.equals(executedSql(request))) { + return errorResponse(new HranaError(UNIQUE_VIOLATION, "SQLITE_CONSTRAINT_UNIQUE")); + } + return successResponse(request); + }); + LibsqlConnection connection = connectionInTransaction(client); + + assertThatThrownBy(() -> endTransaction(connection, command)) + .isInstanceOf(SQLIntegrityConstraintViolationException.class) + .satisfies(thrown -> assertThat(((SQLException) thrown).getSQLState()).isEqualTo("23000")); + } + + @Test + void aBatchStepErrorKeepsTheMappedExceptionAsTheCause() throws SQLException { + ScriptedClient client = new ScriptedClient( + (request, index) -> batchResponse(request, 1, new ArrayList<>())); + LibsqlConnection connection = connectionInTransaction(client); + + assertThatThrownBy(() -> connection.executeBatch(List.of( + new Stmt("INSERT INTO sample VALUES (1)"), + new Stmt("INSERT INTO sample VALUES (2)"), + new Stmt("INSERT INTO sample VALUES (3)")))) + .isInstanceOf(BatchUpdateException.class) + .satisfies(thrown -> { + BatchUpdateException batch = (BatchUpdateException) thrown; + assertThat(batch.getSQLState()).isEqualTo("23000"); + assertThat(batch.getErrorCode()).isEqualTo(19); + assertThat(batch.getCause()) + .isInstanceOf(SQLIntegrityConstraintViolationException.class); + }); + } + + @Test + void aConstraintErrorWithoutACodeFallsBackToMessageCategorization() throws SQLException { + ScriptedClient client = new ScriptedClient((request, index) -> { + if ("INSERT INTO sample VALUES (2)".equals(executedSql(request))) { + return errorResponse(new HranaError(UNIQUE_VIOLATION, null)); + } + return successResponse(request); + }); + LibsqlConnection connection = connectionInTransaction(client); + + assertThatThrownBy(() -> connection.executeStatement(new Stmt("INSERT INTO sample VALUES (2)"))) + .isInstanceOf(SQLIntegrityConstraintViolationException.class) + .satisfies(thrown -> assertThat(((SQLException) thrown).getSQLState()).isEqualTo("23000")); + } + private static int[] countsOfStepsThatRan(int failAt) { int[] counts = new int[failAt]; for (int i = 0; i < counts.length; i++) { @@ -577,6 +686,30 @@ private ScriptedClient(Handler handler) { this.handler = handler; } + /** + * Mirrors the real client's one-shot close path, which does not go through {@link + * #sendPipeline(PipelineReqBody, URI)}: it wraps a server error in a {@link LibsqlException} + * so the connection's mapper sees the protocol error. + */ + @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);