Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -185,7 +185,7 @@ private List<StmtResult> 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)) {
Expand All @@ -197,7 +197,14 @@ private List<StmtResult> 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) {
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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)) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 {
Expand Down Expand Up @@ -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++) {
Expand Down Expand Up @@ -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);
Expand Down
Loading