Skip to content

Commit de31dc6

Browse files
authored
fix(jdbc): map in-transaction Hrana errors through the error mapper (#149)
Fixes #109 Contract: Hrana 3 is the source of truth for transport, values, metadata, batching, transactions, and error reporting. `java.sql` is the source of truth for the JDBC surface. xerial/sqlite-jdbc is a behavioral reference, never a copy target. Inside a transaction, a `StreamResult.Error` was turned into a plain `SQLException(message)`, so SQLState, vendor code, and the constraint category were lost. All four result sites now throw `LibsqlErrorMapper.toSQLException(HranaError)`: `executeStatement`, `beginTransaction`, `endTransaction`, and `extractBatchResults`. `BatchUpdateException` now carries the mapped SQLState/vendor code and keeps the mapped exception as its cause. `Error.code` is mapped when present. Because Hrana 3 says the codes are not yet stabilized, an unrecognized or absent code falls back to categorizing the SQLite message (`UNIQUE constraint failed`, `FOREIGN KEY constraint failed`, `NOT NULL constraint failed`, `CHECK constraint failed`), so a unique violation maps to `SQLIntegrityConstraintViolationException` / `23000` even when sqld sends no code. Base: `c3ef4de03b4188dfb98cc3e8d565b12ea41bec1b` Head: `2612b9e10a4ae23b673c9ef56e102e5d0ad3410d` Red: with the new `LibsqlConnectionTest` cases applied to trunk, 7 fail: the two transaction-mode halves of the unique-violation case and every explicit begin/execute/end/batch case; the auto-commit half passes on trunk (it already used the mapper). Log: `/tmp/xerial-parity-i109/lane-1-regression.log`. Green: 36/36 `LibsqlConnectionTest` cases pass at the head (`/tmp/xerial-parity-i109/green-unit.log`). Lanes (all at head `2612b9e`, logs in `/tmp/xerial-parity-i109/`): 1. Regression — red at trunk, green at head. 2. Batch failure cases — pass; `BatchUpdateException` wraps the mapped cause. 3. Four sites (begin, execute, end, batch) — pass; each maps 23000/19 where a constraint error is scripted. 4. `./mvnw -pl libsql-jdbc test` — 293 pass, BUILD SUCCESS. 5. Conformance — 123 pass, 1 skipped, BUILD SUCCESS. 6. xerial control — 100 pass, 1 skipped, BUILD SUCCESS. 7. `spotless:check` — BUILD SUCCESS. 8. Full reactor — BUILD SUCCESS. 9. Live sqld (`TransactionLifecycleIT#uniqueViolationKeepsItsMappedSqlStateInBothModes`) — pass; real sqld error keeps `23000` in both modes. 10. Package — jar builds. Perf: three interleaved `time ./mvnw -pl libsql-jdbc test` runs per side, repeated once (six samples each). Trunk median 2.108 s, head median 2.111 s (+0.1%), within the 10% rule. Files outside the plan's Files block: - `LibsqlErrorMapper.java`: required to add the `HranaError` overload and keep one mapping path, as the issue asks. - `libsql-itests/.../TransactionLifecycleIT.java`: lane 9 asks for a transaction-error itest; no existing case asserted SQLState against real sqld. No other divergences.
1 parent c3ef4de commit de31dc6

4 files changed

Lines changed: 214 additions & 24 deletions

File tree

‎libsql-itests/src/test/java/com/github/magnusp/libsql/itest/TransactionLifecycleIT.java‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
package com.github.magnusp.libsql.itest;
22

33
import com.github.magnusp.libsql.jdbc.LibsqlConnection;
4+
import org.assertj.core.api.ThrowableAssert;
45
import org.junit.jupiter.api.Test;
56

67
import java.sql.Connection;
78
import java.sql.DriverManager;
89
import java.sql.ResultSet;
910
import java.sql.SQLException;
11+
import java.sql.SQLIntegrityConstraintViolationException;
1012
import java.sql.Statement;
1113
import java.util.concurrent.ExecutorService;
1214
import java.util.concurrent.Executors;
@@ -170,6 +172,30 @@ void failedCommitCanBeRolledBackAndTheConnectionStaysUsable() throws Exception {
170172
}
171173
}
172174

175+
@Test
176+
void uniqueViolationKeepsItsMappedSqlStateInBothModes() throws Exception {
177+
String url = getDriverJdbcUrl();
178+
try (Connection connection = DriverManager.getConnection(url)) {
179+
try (Statement statement = connection.createStatement()) {
180+
statement.execute("CREATE TABLE unique_state (id INTEGER PRIMARY KEY)");
181+
statement.executeUpdate("INSERT INTO unique_state (id) VALUES (1)");
182+
}
183+
184+
assertMappedUniqueViolation(() -> insertRow(connection, "unique_state", 1));
185+
186+
connection.setAutoCommit(false);
187+
assertMappedUniqueViolation(() -> insertRow(connection, "unique_state", 1));
188+
connection.rollback();
189+
}
190+
}
191+
192+
private static void assertMappedUniqueViolation(ThrowableAssert.ThrowingCallable call) {
193+
assertThatThrownBy(call)
194+
.isInstanceOf(SQLIntegrityConstraintViolationException.class)
195+
.hasMessageContaining("UNIQUE constraint failed")
196+
.satisfies(thrown -> assertThat(((SQLException) thrown).getSQLState()).isEqualTo("23000"));
197+
}
198+
173199
private static void assertWriteIsBlockedUntilReleased(
174200
String url, Connection lockHolder, String table, int id) throws Exception {
175201
ExecutorService executor = Executors.newSingleThreadExecutor();

‎libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlConnection.java‎

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ public StmtResult executeStatement(Stmt stmt) throws SQLException {
122122
}
123123
throw new SQLException("Unexpected response: " + ok.response());
124124
}
125-
case StreamResult.Error err -> throw new SQLException(err.error().message());
125+
case StreamResult.Error err -> throw LibsqlErrorMapper.toSQLException(err.error());
126126
};
127127
}
128128
} catch (LibsqlException e) {
@@ -185,7 +185,7 @@ private List<StmtResult> extractBatchResults(PipelineRespBody resp, int expected
185185
}
186186
StreamResult sr = resp.results().get(0);
187187
if (sr instanceof StreamResult.Error err) {
188-
throw new SQLException(err.error().message());
188+
throw LibsqlErrorMapper.toSQLException(err.error());
189189
}
190190
StreamResponse response = ((StreamResult.Ok) sr).response();
191191
if (!(response instanceof StreamResponse.BatchResp batchResp)) {
@@ -197,7 +197,14 @@ private List<StmtResult> extractBatchResults(PipelineRespBody resp, int expected
197197
HranaError error = batchResp.result().stepErrors().get(i);
198198
if (error != null) {
199199
// JDBC stops the batch at the first failure and reports only the steps that ran.
200-
throw new BatchUpdateException(error.message(), steppedUpdateCounts(results));
200+
// The mapped exception is the cause, so SQLState and vendor code survive too.
201+
SQLException mapped = LibsqlErrorMapper.toSQLException(error);
202+
throw new BatchUpdateException(
203+
error.message(),
204+
mapped.getSQLState(),
205+
mapped.getErrorCode(),
206+
steppedUpdateCounts(results),
207+
mapped);
201208
}
202209
StmtResult result = batchResp.result().stepResults().get(i);
203210
if (result == null) {
@@ -251,7 +258,7 @@ private void beginTransaction() throws SQLException {
251258
StreamResult result = resp.results().get(0);
252259
if (result instanceof StreamResult.Error error) {
253260
closeStreamSilently();
254-
throw new SQLException(error.error().message());
261+
throw LibsqlErrorMapper.toSQLException(error.error());
255262
}
256263
if (!(result instanceof StreamResult.Ok ok)
257264
|| !(ok.response() instanceof StreamResponse.Execute)) {
@@ -382,7 +389,7 @@ private void endTransaction(String sql) throws SQLException {
382389
}
383390
StreamResult result = resp.results().get(0);
384391
if (result instanceof StreamResult.Error error) {
385-
throw new SQLException(error.error().message());
392+
throw LibsqlErrorMapper.toSQLException(error.error());
386393
}
387394
if (!(result instanceof StreamResult.Ok ok)
388395
|| !(ok.response() instanceof StreamResponse.Execute)) {
Lines changed: 43 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,70 @@
11
package com.github.magnusp.libsql.jdbc;
22

33
import com.github.magnusp.libsql.client.LibsqlException;
4+
import com.github.magnusp.libsql.hrana.codec.HranaError;
45

5-
import java.sql.SQLClientInfoException;
66
import java.sql.SQLException;
7-
import java.sql.SQLFeatureNotSupportedException;
87
import java.sql.SQLIntegrityConstraintViolationException;
98
import java.sql.SQLInvalidAuthorizationSpecException;
10-
import java.sql.SQLNonTransientConnectionException;
119
import java.sql.SQLSyntaxErrorException;
12-
import java.sql.SQLTimeoutException;
1310
import java.sql.SQLTransientConnectionException;
11+
import java.util.Locale;
1412

1513
public class LibsqlErrorMapper {
1614

1715
public static SQLException toSQLException(LibsqlException e) {
18-
String code = e.getCode();
19-
String message = e.getMessage() != null ? e.getMessage() : "Unknown libSQL error";
16+
return toSQLException(e.getCode(), e.getMessage(), e.getHttpStatus(), e);
17+
}
18+
19+
/**
20+
* Maps a Hrana {@code Error} returned inside a stream, batch, or transaction. The protocol
21+
* leaves {@code code} unstable, so an unrecognized code falls back to categorizing the message
22+
* rather than losing the constraint classification.
23+
*/
24+
public static SQLException toSQLException(HranaError e) {
25+
return toSQLException(e.code(), e.message(), 200, new LibsqlException(e));
26+
}
2027

28+
private static SQLException toSQLException(
29+
String code, String message, int httpStatus, Throwable cause) {
30+
message = message != null ? message : "Unknown libSQL error";
31+
32+
if (isConstraintViolation(code, message)) {
33+
return new SQLIntegrityConstraintViolationException(message, "23000", 19, cause);
34+
}
2135
if (code != null) {
22-
String upper = code.toUpperCase();
23-
if (upper.contains("CONSTRAINT") || upper.contains("SQLITE_CONSTRAINT")) {
24-
return new SQLIntegrityConstraintViolationException(message, "23000", 19, e);
25-
}
26-
if (upper.contains("BUSY") || upper.contains("SQLITE_BUSY") || upper.contains("LOCKED")) {
27-
return new SQLTransientConnectionException(message, "08001", 5, e);
36+
String upper = code.toUpperCase(Locale.ROOT);
37+
if (upper.contains("BUSY") || upper.contains("LOCKED")) {
38+
return new SQLTransientConnectionException(message, "08001", 5, cause);
2839
}
2940
if (upper.contains("SYNTAX") || upper.contains("SQLITE_ERROR")) {
30-
return new SQLSyntaxErrorException(message, "42000", 1, e);
41+
return new SQLSyntaxErrorException(message, "42000", 1, cause);
3142
}
3243
if (upper.contains("AUTH") || upper.contains("JWT")) {
33-
return new SQLInvalidAuthorizationSpecException(message, "28000", e);
44+
return new SQLInvalidAuthorizationSpecException(message, "28000", cause);
3445
}
3546
}
3647

37-
if (e.getHttpStatus() == 401 || e.getHttpStatus() == 403) {
38-
return new SQLInvalidAuthorizationSpecException(message, "28000", e);
48+
if (httpStatus == 401 || httpStatus == 403) {
49+
return new SQLInvalidAuthorizationSpecException(message, "28000", cause);
3950
}
40-
if (e.getHttpStatus() == 429 || e.getHttpStatus() == 503) {
41-
return new SQLTransientConnectionException(message, "08001", e);
51+
if (httpStatus == 429 || httpStatus == 503) {
52+
return new SQLTransientConnectionException(message, "08001", cause);
4253
}
4354

44-
return new SQLException(message, "HY000", e);
55+
return new SQLException(message, "HY000", cause);
56+
}
57+
58+
/**
59+
* A stable category the protocol does not promise: the server may report {@code
60+
* SQLITE_CONSTRAINT_UNIQUE}, the older {@code SQLITE_CONSTRAINT}, or no code at all, so the
61+
* SQLite message text is the fallback signal.
62+
*/
63+
private static boolean isConstraintViolation(String code, String message) {
64+
if (code != null && code.toUpperCase(Locale.ROOT).contains("CONSTRAINT")) {
65+
return true;
66+
}
67+
String upper = message.toUpperCase(Locale.ROOT);
68+
return upper.contains("CONSTRAINT FAILED") || upper.contains("UNIQUE CONSTRAINT");
4569
}
4670
}

‎libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlConnectionTest.java‎

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
import java.net.URI;
2424
import java.sql.BatchUpdateException;
2525
import java.sql.SQLException;
26+
import java.sql.SQLIntegrityConstraintViolationException;
27+
import java.sql.SQLTransientConnectionException;
2628
import java.util.ArrayList;
2729
import java.util.HashMap;
2830
import java.util.List;
@@ -39,6 +41,7 @@ class LibsqlConnectionTest {
3941
private static final String SERVER_ERROR = "transaction end failed on server";
4042
private static final String TRANSPORT_ERROR = "Connection reset by peer";
4143
private static final String BATCH_FAILURE = "UNIQUE constraint failed: sample.id";
44+
private static final String UNIQUE_VIOLATION = "UNIQUE constraint failed: sample.id";
4245

4346
@Test
4447
void getMetaDataIsCachedPerConnection() throws SQLException {
@@ -435,6 +438,112 @@ void batchStepWithNeitherResultNorErrorIsAProtocolError() throws SQLException {
435438
.hasMessageContaining("neither a result nor an error");
436439
}
437440

441+
@ParameterizedTest(name = "autoCommit={0}")
442+
@ValueSource(booleans = {true, false})
443+
void uniqueViolationMapsToTheSameExceptionTypeAndSqlStateInBothModes(boolean autoCommit)
444+
throws SQLException {
445+
ScriptedClient client = new ScriptedClient((request, index) -> {
446+
if ("INSERT INTO sample VALUES (1)".equals(executedSql(request))) {
447+
return errorResponse(new HranaError(UNIQUE_VIOLATION, "SQLITE_CONSTRAINT_UNIQUE"));
448+
}
449+
return successResponse(request);
450+
});
451+
LibsqlConnection connection = new LibsqlConnection(CONFIG, client);
452+
connection.setAutoCommit(autoCommit);
453+
454+
assertThatThrownBy(() -> connection.executeStatement(new Stmt("INSERT INTO sample VALUES (1)")))
455+
.isInstanceOf(SQLIntegrityConstraintViolationException.class)
456+
.hasMessageContaining("UNIQUE constraint failed")
457+
.satisfies(thrown -> {
458+
SQLException mapped = (SQLException) thrown;
459+
assertThat(mapped.getSQLState()).isEqualTo("23000");
460+
assertThat(mapped.getErrorCode()).isEqualTo(19);
461+
});
462+
}
463+
464+
@Test
465+
void failureToBeginTheTransactionIsMapped() throws SQLException {
466+
ScriptedClient client = new ScriptedClient((request, index) -> {
467+
if ("BEGIN".equals(executedSql(request))) {
468+
return errorResponse(new HranaError("database is locked", "SQLITE_BUSY"));
469+
}
470+
return successResponse(request);
471+
});
472+
LibsqlConnection connection = new LibsqlConnection(CONFIG, client);
473+
connection.setAutoCommit(false);
474+
475+
assertThatThrownBy(() -> connection.executeStatement(new Stmt("SELECT 1")))
476+
.isInstanceOf(SQLTransientConnectionException.class)
477+
.hasMessageContaining("database is locked")
478+
.satisfies(thrown -> assertThat(((SQLException) thrown).getSQLState()).isEqualTo("08001"));
479+
}
480+
481+
@Test
482+
void aStatementErrorInsideTheTransactionIsMapped() throws SQLException {
483+
ScriptedClient client = new ScriptedClient((request, index) -> {
484+
if ("INSERT INTO sample VALUES (2)".equals(executedSql(request))) {
485+
return errorResponse(new HranaError(UNIQUE_VIOLATION, "SQLITE_CONSTRAINT_UNIQUE"));
486+
}
487+
return successResponse(request);
488+
});
489+
LibsqlConnection connection = connectionInTransaction(client);
490+
491+
assertThatThrownBy(() -> connection.executeStatement(new Stmt("INSERT INTO sample VALUES (2)")))
492+
.isInstanceOf(SQLIntegrityConstraintViolationException.class)
493+
.satisfies(thrown -> assertThat(((SQLException) thrown).getSQLState()).isEqualTo("23000"));
494+
}
495+
496+
@ParameterizedTest(name = "{0}")
497+
@ValueSource(strings = {"COMMIT", "ROLLBACK"})
498+
void aTransactionEndErrorIsMapped(String command) throws SQLException {
499+
ScriptedClient client = new ScriptedClient((request, index) -> {
500+
if (command.equals(executedSql(request))) {
501+
return errorResponse(new HranaError(UNIQUE_VIOLATION, "SQLITE_CONSTRAINT_UNIQUE"));
502+
}
503+
return successResponse(request);
504+
});
505+
LibsqlConnection connection = connectionInTransaction(client);
506+
507+
assertThatThrownBy(() -> endTransaction(connection, command))
508+
.isInstanceOf(SQLIntegrityConstraintViolationException.class)
509+
.satisfies(thrown -> assertThat(((SQLException) thrown).getSQLState()).isEqualTo("23000"));
510+
}
511+
512+
@Test
513+
void aBatchStepErrorKeepsTheMappedExceptionAsTheCause() throws SQLException {
514+
ScriptedClient client = new ScriptedClient(
515+
(request, index) -> batchResponse(request, 1, new ArrayList<>()));
516+
LibsqlConnection connection = connectionInTransaction(client);
517+
518+
assertThatThrownBy(() -> connection.executeBatch(List.of(
519+
new Stmt("INSERT INTO sample VALUES (1)"),
520+
new Stmt("INSERT INTO sample VALUES (2)"),
521+
new Stmt("INSERT INTO sample VALUES (3)"))))
522+
.isInstanceOf(BatchUpdateException.class)
523+
.satisfies(thrown -> {
524+
BatchUpdateException batch = (BatchUpdateException) thrown;
525+
assertThat(batch.getSQLState()).isEqualTo("23000");
526+
assertThat(batch.getErrorCode()).isEqualTo(19);
527+
assertThat(batch.getCause())
528+
.isInstanceOf(SQLIntegrityConstraintViolationException.class);
529+
});
530+
}
531+
532+
@Test
533+
void aConstraintErrorWithoutACodeFallsBackToMessageCategorization() throws SQLException {
534+
ScriptedClient client = new ScriptedClient((request, index) -> {
535+
if ("INSERT INTO sample VALUES (2)".equals(executedSql(request))) {
536+
return errorResponse(new HranaError(UNIQUE_VIOLATION, null));
537+
}
538+
return successResponse(request);
539+
});
540+
LibsqlConnection connection = connectionInTransaction(client);
541+
542+
assertThatThrownBy(() -> connection.executeStatement(new Stmt("INSERT INTO sample VALUES (2)")))
543+
.isInstanceOf(SQLIntegrityConstraintViolationException.class)
544+
.satisfies(thrown -> assertThat(((SQLException) thrown).getSQLState()).isEqualTo("23000"));
545+
}
546+
438547
private static int[] countsOfStepsThatRan(int failAt) {
439548
int[] counts = new int[failAt];
440549
for (int i = 0; i < counts.length; i++) {
@@ -577,6 +686,30 @@ private ScriptedClient(Handler handler) {
577686
this.handler = handler;
578687
}
579688

689+
/**
690+
* Mirrors the real client's one-shot close path, which does not go through {@link
691+
* #sendPipeline(PipelineReqBody, URI)}: it wraps a server error in a {@link LibsqlException}
692+
* so the connection's mapper sees the protocol error.
693+
*/
694+
@Override
695+
public StmtResult executeOneShot(Stmt stmt) {
696+
PipelineReqBody request = new PipelineReqBody(null, List.of(
697+
new StreamRequest.Execute(stmt),
698+
StreamRequest.Close.INSTANCE));
699+
requests.add(request);
700+
PipelineRespBody response = handler.handle(request, requests.size() - 1);
701+
StreamResult first = response.results().get(0);
702+
return switch (first) {
703+
case StreamResult.Ok ok -> {
704+
if (ok.response() instanceof StreamResponse.Execute exec) {
705+
yield exec.result();
706+
}
707+
throw new LibsqlException("Unexpected stream response: " + ok.response(), null, 200);
708+
}
709+
case StreamResult.Error err -> throw new LibsqlException(err.error());
710+
};
711+
}
712+
580713
@Override
581714
public PipelineRespBody sendPipeline(PipelineReqBody request, URI targetUri) {
582715
requests.add(request);

0 commit comments

Comments
 (0)