diff --git a/README.md b/README.md index 9aadbc9..ae5e173 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,26 @@ public class Example { } ``` +### Connection URL parameters + +The JDBC URL accepts these query parameters. The same keys work as `Properties` passed to +`DriverManager.getConnection`; a query parameter wins over the matching property. + +| Parameter | Values | Default | Notes | +| --- | --- | --- | --- | +| `authToken` | token | none | Sent as a bearer token. `jwt` and `password` are accepted aliases. | +| `busyTimeout` | milliseconds | `5000` | How long a request retries while the server reports busy. | +| `connectTimeout` | milliseconds | `10000` | HTTP connect and request timeout. | +| `encoding` | `json`, `protobuf` | `json` | `protobuf` requires a `ws://` or `wss://` URL. | +| `transactionMode` | `deferred`, `immediate`, `exclusive` | `deferred` | Mode used by the next transaction begin. Case-insensitive; an unknown value fails at connect. | +| `tls`, `ssl` | `true` | unset | Upgrades `http` to `https` and `ws` to `wss`. | + +`Connection.setReadOnly` is the JDBC hint and does not reject writes. A connection is read-only +only when the server enforces it. With sqld, start the server with `--auth-jwt-key-file` and pass a +JWT whose `a` claim is `ro` as `authToken`; sqld then refuses writes on that connection. sqld +v0.24.33 refuses `PRAGMA query_only`, so there is no session-level setting that achieves the same +thing. + ## Development & Building ### Prerequisites diff --git a/libsql-client/src/main/java/io/github/magnusp/libsql/client/LibsqlHttpClient.java b/libsql-client/src/main/java/io/github/magnusp/libsql/client/LibsqlHttpClient.java index 0132f89..eb2b7f6 100644 --- a/libsql-client/src/main/java/io/github/magnusp/libsql/client/LibsqlHttpClient.java +++ b/libsql-client/src/main/java/io/github/magnusp/libsql/client/LibsqlHttpClient.java @@ -100,7 +100,13 @@ public StmtResult executeOnStream(Stmt stmt) { StreamResult result; synchronized (streamLock) { PipelineReqBody req = new PipelineReqBody(baton, List.of(new StreamRequest.Execute(stmt))); - PipelineRespBody resp = sendPipeline(req, baseUrl); + PipelineRespBody resp; + try { + resp = sendPipeline(req, baseUrl); + } catch (LibsqlException e) { + clearStreamOnAuthFailure(e); + throw e; + } updateStreamState(resp); result = firstResult(resp); } @@ -135,7 +141,13 @@ public BatchResult executeBatchOnStream(Batch batch) { StreamResult result; synchronized (streamLock) { PipelineReqBody req = new PipelineReqBody(baton, List.of(new StreamRequest.BatchReq(batch))); - PipelineRespBody resp = sendPipeline(req, baseUrl); + PipelineRespBody resp; + try { + resp = sendPipeline(req, baseUrl); + } catch (LibsqlException e) { + clearStreamOnAuthFailure(e); + throw e; + } updateStreamState(resp); result = firstResult(resp); } @@ -196,6 +208,17 @@ private void updateStreamState(PipelineRespBody resp) { } } + /** + * The server invalidates the baton when it refuses a request, so the next statement must open a + * fresh stream instead of replaying a dead one. Callers hold {@link #streamLock}. + */ + private void clearStreamOnAuthFailure(LibsqlException e) { + if (e.getHttpStatus() == 401 || e.getHttpStatus() == 403) { + baton = null; + baseUrl = null; + } + } + public PipelineRespBody sendPipeline(PipelineReqBody reqBody, URI targetUri) { return sendPipelineWithRetry(reqBody, targetUri != null ? targetUri : pipelineUri); } @@ -239,7 +262,7 @@ private PipelineRespBody sendPipelineWithRetry(PipelineReqBody reqBody, URI uri) } catch (Exception ignored) { } if (err != null) { - throw new LibsqlException(err); + throw new LibsqlException(err.message(), err.code(), status); } throw new LibsqlException("HTTP error " + status + ": " + new String(response.body()), null, status); } diff --git a/libsql-client/src/test/java/io/github/magnusp/libsql/client/LibsqlHttpClientAuthTest.java b/libsql-client/src/test/java/io/github/magnusp/libsql/client/LibsqlHttpClientAuthTest.java new file mode 100644 index 0000000..28b116a --- /dev/null +++ b/libsql-client/src/test/java/io/github/magnusp/libsql/client/LibsqlHttpClientAuthTest.java @@ -0,0 +1,102 @@ +package io.github.magnusp.libsql.client; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; +import io.github.magnusp.libsql.hrana.codec.Stmt; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class LibsqlHttpClientAuthTest { + + private static final byte[] FORBIDDEN = + "{\"error\":\"Authorization forbidden: nope\"}".getBytes(StandardCharsets.UTF_8); + + private static final byte[] STREAM_STARTED = + ("{\"baton\":\"baton-1\",\"results\":[{\"type\":\"ok\",\"response\":" + + "{\"type\":\"execute\",\"result\":{\"cols\":[],\"rows\":[]," + + "\"affected_row_count\":0,\"last_insert_rowid\":null}}}]}") + .getBytes(StandardCharsets.UTF_8); + + @Test + void forbiddenResponseSurfacesTheServerMessageAndStatus() throws Exception { + try (Server server = new Server(exchange -> respond(exchange, 403, FORBIDDEN))) { + LibsqlHttpClient client = server.client(); + + assertThatThrownBy(() -> client.executeOneShot(new Stmt("SELECT 1"))) + .isInstanceOfSatisfying(LibsqlException.class, e -> { + assertThat(e.getMessage()).isEqualTo("Authorization forbidden: nope"); + assertThat(e.getHttpStatus()).isEqualTo(403); + }); + } + } + + @Test + void forbiddenResponseOnAStreamDropsTheBaton() throws Exception { + AtomicInteger request = new AtomicInteger(); + try (Server server = new Server(exchange -> { + if (request.getAndIncrement() == 1) { + respond(exchange, 403, FORBIDDEN); + } else { + respond(exchange, 200, STREAM_STARTED); + } + })) { + LibsqlHttpClient client = server.client(); + client.executeOnStream(new Stmt("SELECT 1")); + assertThat(client.hasOpenStream()).isTrue(); + + assertThatThrownBy(() -> client.executeOnStream(new Stmt("INSERT INTO t VALUES (1)"))) + .isInstanceOfSatisfying( + LibsqlException.class, + e -> assertThat(e.getHttpStatus()).isEqualTo(403)); + assertThat(client.hasOpenStream()).isFalse(); + + client.executeOnStream(new Stmt("SELECT 2")); + assertThat(server.bodies.get(2)).doesNotContain("baton-1"); + } + } + + private static void respond(HttpExchange exchange, int status, byte[] body) throws IOException { + exchange.sendResponseHeaders(status, body.length); + try (OutputStream out = exchange.getResponseBody()) { + out.write(body); + } + } + + private static final class Server implements AutoCloseable { + + private final HttpServer server; + private final List bodies = new CopyOnWriteArrayList<>(); + + Server(HttpHandler handler) throws IOException { + server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0); + server.createContext("/v3/pipeline", exchange -> { + bodies.add(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)); + handler.handle(exchange); + }); + server.start(); + } + + LibsqlHttpClient client() { + LibsqlClientConfig config = new LibsqlClientConfig( + "http://127.0.0.1:" + server.getAddress().getPort(), null); + return new LibsqlHttpClient(config); + } + + @Override + public void close() { + server.stop(0); + } + } +} diff --git a/libsql-hrana-codec/src/main/java/io/github/magnusp/libsql/hrana/codec/DirectHranaJsonCodec.java b/libsql-hrana-codec/src/main/java/io/github/magnusp/libsql/hrana/codec/DirectHranaJsonCodec.java index f05043a..2eca22b 100644 --- a/libsql-hrana-codec/src/main/java/io/github/magnusp/libsql/hrana/codec/DirectHranaJsonCodec.java +++ b/libsql-hrana-codec/src/main/java/io/github/magnusp/libsql/hrana/codec/DirectHranaJsonCodec.java @@ -570,17 +570,26 @@ private DescribeCol parseDescribeCol(FastJsonParser p) throws IOException { private HranaError parseError(FastJsonParser p) throws IOException { p.expectObjectStart(); - String message = ""; + String message = null; String code = null; while (!p.checkObjectEnd()) { String field = p.nextFieldName(); switch (field) { case "message" -> message = p.nextString(); + // A transport-level refusal carries only an "error" string, not a Hrana Error. + case "error" -> { + String text = p.peekString() ? p.nextString() : null; + if (text == null) { + p.skipValue(); + } else if (message == null) { + message = text; + } + } case "code" -> code = p.nextStringOrNull(); default -> p.skipValue(); } } - return new HranaError(message, code); + return new HranaError(message != null ? message : "", code); } // ------------------------------------------------------------- diff --git a/libsql-hrana-codec/src/main/java/io/github/magnusp/libsql/hrana/codec/JacksonHranaJsonCodec.java b/libsql-hrana-codec/src/main/java/io/github/magnusp/libsql/hrana/codec/JacksonHranaJsonCodec.java index ef02197..b5f03c0 100644 --- a/libsql-hrana-codec/src/main/java/io/github/magnusp/libsql/hrana/codec/JacksonHranaJsonCodec.java +++ b/libsql-hrana-codec/src/main/java/io/github/magnusp/libsql/hrana/codec/JacksonHranaJsonCodec.java @@ -521,17 +521,27 @@ private HranaError readError(JsonParser p) throws IOException { if (p.currentToken() != JsonToken.START_OBJECT) { throw new IOException("Expected START_OBJECT for Error"); } - String message = ""; + String message = null; String code = null; while (p.nextToken() != JsonToken.END_OBJECT) { String field = p.currentName(); p.nextToken(); switch (field) { case "message" -> message = p.getText(); + // A transport-level refusal carries only an "error" string, not a Hrana Error. + case "error" -> { + if (p.currentToken() == JsonToken.VALUE_STRING) { + if (message == null) { + message = p.getText(); + } + } else { + p.skipChildren(); + } + } case "code" -> code = p.getText(); default -> p.skipChildren(); } } - return new HranaError(message, code); + return new HranaError(message != null ? message : "", code); } } diff --git a/libsql-hrana-codec/src/test/java/io/github/magnusp/libsql/hrana/codec/DirectHranaJsonCodecTest.java b/libsql-hrana-codec/src/test/java/io/github/magnusp/libsql/hrana/codec/DirectHranaJsonCodecTest.java index 112b0b9..09589a2 100644 --- a/libsql-hrana-codec/src/test/java/io/github/magnusp/libsql/hrana/codec/DirectHranaJsonCodecTest.java +++ b/libsql-hrana-codec/src/test/java/io/github/magnusp/libsql/hrana/codec/DirectHranaJsonCodecTest.java @@ -2,7 +2,9 @@ import org.junit.jupiter.api.Test; +import java.io.ByteArrayInputStream; import java.io.IOException; +import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.util.List; @@ -48,4 +50,24 @@ void testSerializeSupplementaryCharactersAsUtf8() throws IOException { assertThat(json).doesNotContain("?"); } + @Test + void testDeserializeErrorFallsBackToTheErrorField() throws IOException { + HranaError error = codec.deserializeError(errorBody("{\"error\":\"Authorization forbidden: nope\"}")); + + assertThat(error.message()).isEqualTo("Authorization forbidden: nope"); + assertThat(error.code()).isNull(); + } + + @Test + void testDeserializeErrorPrefersTheMessageField() throws IOException { + HranaError error = codec.deserializeError( + errorBody("{\"message\":\"constraint failed\",\"error\":\"nope\",\"code\":\"SQLITE_CONSTRAINT\"}")); + + assertThat(error.message()).isEqualTo("constraint failed"); + assertThat(error.code()).isEqualTo("SQLITE_CONSTRAINT"); + } + + private static InputStream errorBody(String json) { + return new ByteArrayInputStream(json.getBytes(StandardCharsets.UTF_8)); + } } diff --git a/libsql-itests/src/test/java/io/github/magnusp/libsql/itest/AuthTokenIT.java b/libsql-itests/src/test/java/io/github/magnusp/libsql/itest/AuthTokenIT.java new file mode 100644 index 0000000..b62a541 --- /dev/null +++ b/libsql-itests/src/test/java/io/github/magnusp/libsql/itest/AuthTokenIT.java @@ -0,0 +1,154 @@ +package io.github.magnusp.libsql.itest; + +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.MountableFile; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.GeneralSecurityException; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.NoSuchAlgorithmException; +import java.security.Signature; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.SQLInvalidAuthorizationSpecException; +import java.sql.Statement; +import java.time.Duration; +import java.util.Base64; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@Testcontainers +public class AuthTokenIT { + + private static final int PORT = 8080; + private static final String PUBLIC_KEY_IN_CONTAINER = "/etc/sqld/libsql-jwt-public.pem"; + + private static final KeyPair KEY_PAIR = generateKeyPair(); + private static final String READ_ONLY_TOKEN = mintToken("ro"); + private static final String READ_WRITE_TOKEN = mintToken("rw"); + + @Container + private static final GenericContainer SQLD = new GenericContainer<>(SqldContainerBase.LIBSQL_IMAGE) + .withExposedPorts(PORT) + .withEnv("SQLD_NODE", "primary") + .withEnv("SQLD_AUTH_JWT_KEY_FILE", PUBLIC_KEY_IN_CONTAINER) + .withCopyFileToContainer(MountableFile.forHostPath(writePublicKey()), PUBLIC_KEY_IN_CONTAINER) + // Under auth the pipeline endpoint answers 401, so readiness comes from /health. + .waitingFor(Wait.forHttp("/health") + .forPort(PORT) + .forStatusCode(200) + .withStartupTimeout(Duration.ofSeconds(60))); + + @Test + void aRequestWithoutATokenIsRefused() throws Exception { + try (Connection connection = DriverManager.getConnection(url(null)); + Statement statement = connection.createStatement()) { + assertThatThrownBy(() -> statement.execute("SELECT 1")) + .isInstanceOfSatisfying(SQLInvalidAuthorizationSpecException.class, e -> { + assertThat(e.getSQLState()).isEqualTo("28000"); + }); + } + } + + @Test + void aReadOnlyTokenCanRead() throws Exception { + createTable(); + try (Connection connection = DriverManager.getConnection(url(READ_ONLY_TOKEN)); + Statement statement = connection.createStatement(); + ResultSet rows = statement.executeQuery("SELECT COUNT(*) FROM auth_rows")) { + assertThat(rows.next()).isTrue(); + assertThat(rows.getInt(1)).isGreaterThanOrEqualTo(0); + } + } + + @Test + void aReadOnlyTokenCannotWriteAndTheConnectionStillReads() throws Exception { + createTable(); + try (Connection connection = DriverManager.getConnection(url(READ_ONLY_TOKEN)); + Statement statement = connection.createStatement()) { + assertThatThrownBy(() -> statement.executeUpdate("INSERT INTO auth_rows (id) VALUES (9001)")) + .isInstanceOfSatisfying(SQLInvalidAuthorizationSpecException.class, e -> { + assertThat(e.getSQLState()).isEqualTo("28000"); + assertThat(e.getMessage()).contains("Authorization forbidden"); + }); + + try (ResultSet rows = statement.executeQuery("SELECT COUNT(*) FROM auth_rows")) { + assertThat(rows.next()).isTrue(); + } + } + } + + @Test + void aReadWriteTokenCanWrite() throws Exception { + createTable(); + try (Connection connection = DriverManager.getConnection(url(READ_WRITE_TOKEN)); + Statement statement = connection.createStatement()) { + assertThat(statement.executeUpdate("INSERT OR REPLACE INTO auth_rows (id) VALUES (9002)")) + .isEqualTo(1); + } + } + + private static void createTable() throws SQLException { + try (Connection connection = DriverManager.getConnection(url(READ_WRITE_TOKEN)); + Statement statement = connection.createStatement()) { + statement.execute("CREATE TABLE IF NOT EXISTS auth_rows (id INTEGER PRIMARY KEY)"); + } + } + + private static String url(String token) { + String base = "jdbc:libsql:http://" + SQLD.getHost() + ":" + SQLD.getMappedPort(PORT); + return token == null ? base : base + "?authToken=" + token; + } + + private static KeyPair generateKeyPair() { + try { + return KeyPairGenerator.getInstance("Ed25519").generateKeyPair(); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("Ed25519 is required to mint a sqld token", e); + } + } + + private static Path writePublicKey() { + String pem = "-----BEGIN PUBLIC KEY-----\n" + + Base64.getMimeEncoder(64, new byte[] {'\n'}).encodeToString(KEY_PAIR.getPublic().getEncoded()) + + "\n-----END PUBLIC KEY-----\n"; + try { + Path file = Files.createTempFile("libsql-jwt-", ".pem"); + file.toFile().deleteOnExit(); + // The copy keeps the source mode, and sqld runs as an unprivileged container user. + file.toFile().setReadable(true, false); + return Files.writeString(file, pem, StandardCharsets.UTF_8); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + private static String mintToken(String role) { + Base64.Encoder encoder = Base64.getUrlEncoder().withoutPadding(); + String header = encoder.encodeToString( + "{\"alg\":\"EdDSA\",\"typ\":\"JWT\"}".getBytes(StandardCharsets.UTF_8)); + String payload = encoder.encodeToString( + ("{\"a\":\"" + role + "\"}").getBytes(StandardCharsets.UTF_8)); + String signingInput = header + "." + payload; + try { + Signature signer = Signature.getInstance("Ed25519"); + signer.initSign(KEY_PAIR.getPrivate()); + signer.update(signingInput.getBytes(StandardCharsets.UTF_8)); + return signingInput + "." + encoder.encodeToString(signer.sign()); + } catch (GeneralSecurityException e) { + throw new IllegalStateException("could not mint a " + role + " token", e); + } + } +} diff --git a/libsql-itests/src/test/java/io/github/magnusp/libsql/itest/SqldTransactionSupport.java b/libsql-itests/src/test/java/io/github/magnusp/libsql/itest/SqldTransactionSupport.java new file mode 100644 index 0000000..07a3776 --- /dev/null +++ b/libsql-itests/src/test/java/io/github/magnusp/libsql/itest/SqldTransactionSupport.java @@ -0,0 +1,71 @@ +package io.github.magnusp.libsql.itest; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import static org.assertj.core.api.Assertions.assertThat; + +final class SqldTransactionSupport { + + private SqldTransactionSupport() {} + + static void createTable(Connection connection, String table) throws SQLException { + try (Statement statement = connection.createStatement()) { + statement.execute("CREATE TABLE " + table + " (id INTEGER PRIMARY KEY)"); + } + } + + static int insertRow(Connection connection, String table, int id) throws SQLException { + try (Statement statement = connection.createStatement()) { + return statement.executeUpdate("INSERT INTO " + table + " (id) VALUES (" + id + ")"); + } + } + + static void assertWriteIsBlockedUntilReleased( + String url, Connection lockHolder, String table, int id) throws Exception { + ExecutorService executor = Executors.newSingleThreadExecutor(); + try { + Future insert = executor.submit(() -> { + try (Connection writer = DriverManager.getConnection(url)) { + return insertRow(writer, table, id); + } catch (SQLException e) { + return e; + } + }); + + Object resultWhileLocked; + try { + resultWhileLocked = insert.get(300, TimeUnit.MILLISECONDS); + assertThat(resultWhileLocked) + .as("writer must not succeed while the write lock is held") + .isNotEqualTo(1); + } catch (TimeoutException blocked) { + resultWhileLocked = null; + } + + lockHolder.setAutoCommit(true); + + if (insert.isDone()) { + Object completed = insert.get(); + if (completed instanceof SQLException) { + try (Connection writer = java.sql.DriverManager.getConnection(url)) { + assertThat(insertRow(writer, table, id)).isEqualTo(1); + } + } else { + assertThat(completed).isEqualTo(1); + } + } else { + assertThat(insert.get(20, TimeUnit.SECONDS)).isEqualTo(1); + } + } finally { + executor.shutdownNow(); + } + } +} diff --git a/libsql-itests/src/test/java/io/github/magnusp/libsql/itest/TransactionLifecycleIT.java b/libsql-itests/src/test/java/io/github/magnusp/libsql/itest/TransactionLifecycleIT.java index 1cf930b..853b2e0 100644 --- a/libsql-itests/src/test/java/io/github/magnusp/libsql/itest/TransactionLifecycleIT.java +++ b/libsql-itests/src/test/java/io/github/magnusp/libsql/itest/TransactionLifecycleIT.java @@ -14,8 +14,10 @@ import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; +import static io.github.magnusp.libsql.itest.SqldTransactionSupport.assertWriteIsBlockedUntilReleased; +import static io.github.magnusp.libsql.itest.SqldTransactionSupport.createTable; +import static io.github.magnusp.libsql.itest.SqldTransactionSupport.insertRow; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -195,57 +197,4 @@ private static void assertMappedUniqueViolation(ThrowableAssert.ThrowingCallable .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(); - try { - Future insert = executor.submit(() -> { - try (Connection writer = DriverManager.getConnection(url)) { - return insertRow(writer, table, id); - } catch (SQLException e) { - return e; - } - }); - - Object resultWhileLocked; - try { - resultWhileLocked = insert.get(300, TimeUnit.MILLISECONDS); - assertThat(resultWhileLocked) - .as("writer must not succeed while the write lock is held") - .isNotEqualTo(1); - } catch (TimeoutException blocked) { - resultWhileLocked = null; - } - - lockHolder.setAutoCommit(true); - - if (insert.isDone()) { - Object completed = insert.get(); - if (completed instanceof SQLException) { - try (Connection writer = DriverManager.getConnection(url)) { - assertThat(insertRow(writer, table, id)).isEqualTo(1); - } - } else { - assertThat(completed).isEqualTo(1); - } - } else { - assertThat(insert.get(20, TimeUnit.SECONDS)).isEqualTo(1); - } - } finally { - executor.shutdownNow(); - } - } - - private static void createTable(Connection connection, String table) throws SQLException { - try (Statement statement = connection.createStatement()) { - statement.execute("CREATE TABLE " + table + " (id INTEGER PRIMARY KEY)"); - } - } - - private static int insertRow(Connection connection, String table, int id) throws SQLException { - try (Statement statement = connection.createStatement()) { - return statement.executeUpdate("INSERT INTO " + table + " (id) VALUES (" + id + ")"); - } - } } diff --git a/libsql-itests/src/test/java/io/github/magnusp/libsql/itest/TransactionModeIT.java b/libsql-itests/src/test/java/io/github/magnusp/libsql/itest/TransactionModeIT.java new file mode 100644 index 0000000..763e9ce --- /dev/null +++ b/libsql-itests/src/test/java/io/github/magnusp/libsql/itest/TransactionModeIT.java @@ -0,0 +1,55 @@ +package io.github.magnusp.libsql.itest; + +import io.github.magnusp.libsql.jdbc.LibsqlConnection; +import org.junit.jupiter.api.Test; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.Statement; + +import static io.github.magnusp.libsql.itest.SqldTransactionSupport.assertWriteIsBlockedUntilReleased; +import static io.github.magnusp.libsql.itest.SqldTransactionSupport.createTable; +import static io.github.magnusp.libsql.itest.SqldTransactionSupport.insertRow; +import static org.assertj.core.api.Assertions.assertThat; + +public class TransactionModeIT extends SqldContainerBase { + + @Test + void urlImmediateTransactionModeHoldsTheWriteLockFromTheFirstStatement() throws Exception { + String url = "jdbc:libsql:" + getHttpUrl() + "?transactionMode=immediate"; + try (Connection lockHolder = DriverManager.getConnection(url); + Statement statement = lockHolder.createStatement()) { + createTable(lockHolder, "url_immediate_lock"); + assertThat(lockHolder.unwrap(LibsqlConnection.class).getTransactionMode()) + .isEqualTo(LibsqlConnection.TransactionMode.IMMEDIATE); + + lockHolder.setAutoCommit(false); + try (ResultSet rows = statement.executeQuery("SELECT COUNT(*) FROM url_immediate_lock")) { + assertThat(rows.next()).isTrue(); + } + + assertWriteIsBlockedUntilReleased(url, lockHolder, "url_immediate_lock", 1); + } + } + + @Test + void urlDeferredTransactionModeDoesNotHoldTheWriteLockFromTheFirstStatement() throws Exception { + String url = "jdbc:libsql:" + getHttpUrl() + "?transactionMode=deferred"; + try (Connection lockHolder = DriverManager.getConnection(url); + Statement statement = lockHolder.createStatement(); + Connection writer = DriverManager.getConnection(url)) { + createTable(lockHolder, "url_deferred_lock"); + assertThat(lockHolder.unwrap(LibsqlConnection.class).getTransactionMode()) + .isEqualTo(LibsqlConnection.TransactionMode.DEFERRED); + + lockHolder.setAutoCommit(false); + try (ResultSet rows = statement.executeQuery("SELECT COUNT(*) FROM url_deferred_lock")) { + assertThat(rows.next()).isTrue(); + } + + assertThat(insertRow(writer, "url_deferred_lock", 1)).isEqualTo(1); + lockHolder.rollback(); + } + } +} diff --git a/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/LibsqlConnection.java b/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/LibsqlConnection.java index 9baeeff..1574444 100644 --- a/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/LibsqlConnection.java +++ b/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/LibsqlConnection.java @@ -68,7 +68,7 @@ public class LibsqlConnection implements Connection { private boolean readOnly = false; private int transactionIsolation = TRANSACTION_SERIALIZABLE; private boolean closed = false; - private TransactionMode transactionMode = TransactionMode.DEFERRED; + private TransactionMode transactionMode; private LibsqlDatabaseMetaData meta; private int nextSavepointId = 0; private boolean transactionActive = false; @@ -112,9 +112,18 @@ public LibsqlConnection(LibsqlClientConfig config, LibsqlClient client) { } public LibsqlConnection(LibsqlClientConfig config, LibsqlClient client, String jdbcUrl) { + this(config, client, jdbcUrl, TransactionMode.DEFERRED); + } + + LibsqlConnection( + LibsqlClientConfig config, + LibsqlClient client, + String jdbcUrl, + TransactionMode transactionMode) { this.config = config; this.client = client; this.jdbcUrl = jdbcUrl; + this.transactionMode = transactionMode; } public LibsqlClientConfig getConfig() { diff --git a/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/LibsqlDriver.java b/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/LibsqlDriver.java index 4fab91e..34aeb2e 100644 --- a/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/LibsqlDriver.java +++ b/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/LibsqlDriver.java @@ -35,7 +35,8 @@ public Connection connect(String url, Properties info) throws SQLException { if (!acceptsURL(url)) { return null; } - LibsqlClientConfig config = LibsqlUrlParser.parse(url, info); + ParsedConnection parsed = LibsqlUrlParser.parseConnection(url, info); + LibsqlClientConfig config = parsed.client(); LibsqlClient client; if (isWebSocketUrl(config.url())) { HranaWsCodec codec = config.encoding() == LibsqlClientConfig.Encoding.PROTOBUF @@ -45,7 +46,8 @@ public Connection connect(String url, Properties info) throws SQLException { } else { client = new LibsqlHttpClient(config); } - return new LibsqlConnection(config, client, LibsqlUrlParser.metadataUrl(url)); + return new LibsqlConnection( + config, client, LibsqlUrlParser.metadataUrl(url), parsed.transactionMode()); } private static boolean isWebSocketUrl(String url) { diff --git a/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/LibsqlUrlParser.java b/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/LibsqlUrlParser.java index ffa8611..af4c02f 100644 --- a/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/LibsqlUrlParser.java +++ b/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/LibsqlUrlParser.java @@ -33,6 +33,10 @@ public static boolean acceptsUrl(String url) { } public static LibsqlClientConfig parse(String url, Properties info) throws SQLException { + return parseConnection(url, info).client(); + } + + static ParsedConnection parseConnection(String url, Properties info) throws SQLException { if (!acceptsUrl(url)) { throw new SQLException("Invalid JDBC URL: " + url); } @@ -45,8 +49,10 @@ public static LibsqlClientConfig parse(String url, Properties info) throws SQLEx resolveDuration("connectTimeout", queryParams, info, LibsqlClientConfig.DEFAULT_CONNECT_TIMEOUT); Duration busyTimeout = resolveDuration("busyTimeout", queryParams, info, LibsqlClientConfig.DEFAULT_BUSY_TIMEOUT); - return new LibsqlClientConfig( - buildEndpoint(uri, scheme), authToken, connectTimeout, busyTimeout, encoding); + LibsqlConnection.TransactionMode transactionMode = resolveTransactionMode(queryParams, info); + return new ParsedConnection( + new LibsqlClientConfig(buildEndpoint(uri, scheme), authToken, connectTimeout, busyTimeout, encoding), + transactionMode); } /** @@ -158,6 +164,24 @@ private static Duration resolveDuration( return defaultValue; } + private static LibsqlConnection.TransactionMode resolveTransactionMode( + Map queryParams, Properties info) throws SQLException { + String value = queryParams.get("transactionMode"); + if (value == null && info != null) { + value = info.getProperty("transactionMode"); + } + if (value == null) { + return LibsqlConnection.TransactionMode.DEFERRED; + } + return switch (value.toLowerCase(Locale.ROOT)) { + case "deferred" -> LibsqlConnection.TransactionMode.DEFERRED; + case "immediate" -> LibsqlConnection.TransactionMode.IMMEDIATE; + case "exclusive" -> LibsqlConnection.TransactionMode.EXCLUSIVE; + default -> throw new SQLException("Unsupported transactionMode '" + value + + "': expected deferred, immediate, or exclusive"); + }; + } + /** Keeps a bad timeout a JDBC error instead of leaking {@link NumberFormatException}. */ private static Duration parseDuration(String name, String value) throws SQLException { try { diff --git a/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/ParsedConnection.java b/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/ParsedConnection.java new file mode 100644 index 0000000..5991462 --- /dev/null +++ b/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/ParsedConnection.java @@ -0,0 +1,5 @@ +package io.github.magnusp.libsql.jdbc; + +import io.github.magnusp.libsql.client.LibsqlClientConfig; + +record ParsedConnection(LibsqlClientConfig client, LibsqlConnection.TransactionMode transactionMode) {} diff --git a/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/LibsqlConnectionTransactionModeTest.java b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/LibsqlConnectionTransactionModeTest.java new file mode 100644 index 0000000..826824b --- /dev/null +++ b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/LibsqlConnectionTransactionModeTest.java @@ -0,0 +1,117 @@ +package io.github.magnusp.libsql.jdbc; + +import io.github.magnusp.libsql.client.LibsqlClient; +import io.github.magnusp.libsql.client.LibsqlClientConfig; +import io.github.magnusp.libsql.hrana.codec.Batch; +import io.github.magnusp.libsql.hrana.codec.BatchResult; +import io.github.magnusp.libsql.hrana.codec.Stmt; +import io.github.magnusp.libsql.hrana.codec.StmtResult; +import org.junit.jupiter.api.Test; + +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class LibsqlConnectionTransactionModeTest { + + private static final LibsqlClientConfig CONFIG = new LibsqlClientConfig("http://localhost:1", null); + + @Test + void immediateConnectionBeginsItsFirstTransactionImmediately() throws SQLException { + RecordingClient client = new RecordingClient(); + LibsqlConnection connection = new LibsqlConnection( + CONFIG, client, null, LibsqlConnection.TransactionMode.IMMEDIATE); + + connection.setAutoCommit(false); + connection.executeStatement(new Stmt("INSERT INTO t (id) VALUES (1)")); + + assertThat(client.sent).first().isEqualTo("BEGIN IMMEDIATE"); + assertThat(connection.getTransactionMode()).isEqualTo(LibsqlConnection.TransactionMode.IMMEDIATE); + } + + @Test + void exclusiveConnectionBeginsItsFirstTransactionExclusively() throws SQLException { + RecordingClient client = new RecordingClient(); + LibsqlConnection connection = new LibsqlConnection( + CONFIG, client, null, LibsqlConnection.TransactionMode.EXCLUSIVE); + + connection.setAutoCommit(false); + connection.executeStatement(new Stmt("INSERT INTO t (id) VALUES (1)")); + + assertThat(client.sent).first().isEqualTo("BEGIN EXCLUSIVE"); + } + + @Test + void connectionsWithoutAnExplicitModeBeginDeferred() throws SQLException { + RecordingClient client = new RecordingClient(); + LibsqlConnection connection = new LibsqlConnection(CONFIG, client); + + connection.setAutoCommit(false); + connection.executeStatement(new Stmt("INSERT INTO t (id) VALUES (1)")); + + assertThat(client.sent).first().isEqualTo("BEGIN"); + assertThat(connection.getTransactionMode()).isEqualTo(LibsqlConnection.TransactionMode.DEFERRED); + } + + private static final class RecordingClient implements LibsqlClient { + + private final List sent = new ArrayList<>(); + private boolean openStream; + + @Override + public StmtResult executeOneShot(Stmt stmt) { + return emptyResult(); + } + + @Override + public StmtResult executeOnStream(Stmt stmt) { + sent.add(stmt.sql()); + openStream = true; + return emptyResult(); + } + + @Override + public BatchResult executeBatchOneShot(Batch batch) { + return emptyBatch(batch); + } + + @Override + public BatchResult executeBatchOnStream(Batch batch) { + openStream = true; + return emptyBatch(batch); + } + + @Override + public boolean hasOpenStream() { + return openStream; + } + + @Override + public void closeStream() { + openStream = false; + } + + @Override + public void close() { + openStream = false; + } + + private static StmtResult emptyResult() { + return new StmtResult(List.of(), List.of(), 0, null); + } + + private static BatchResult emptyBatch(Batch batch) { + Map results = new LinkedHashMap<>(); + for (int i = 0; i < batch.steps().size(); i++) { + if (batch.steps().get(i).stmt() != null) { + results.put(i, emptyResult()); + } + } + return new BatchResult(results, Map.of()); + } + } +} diff --git a/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/LibsqlErrorMapperTest.java b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/LibsqlErrorMapperTest.java new file mode 100644 index 0000000..5840f3d --- /dev/null +++ b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/LibsqlErrorMapperTest.java @@ -0,0 +1,24 @@ +package io.github.magnusp.libsql.jdbc; + +import io.github.magnusp.libsql.client.LibsqlException; +import org.junit.jupiter.api.Test; + +import java.sql.SQLException; +import java.sql.SQLInvalidAuthorizationSpecException; + +import static org.assertj.core.api.Assertions.assertThat; + +class LibsqlErrorMapperTest { + + @Test + void aForbiddenHttpResponseMapsToAnAuthorizationFailure() { + SQLException mapped = LibsqlErrorMapper.toSQLException( + new LibsqlException("Authorization forbidden: nope", null, 403)); + + assertThat((Throwable) mapped) + .isInstanceOfSatisfying(SQLInvalidAuthorizationSpecException.class, e -> { + assertThat(e.getMessage()).isEqualTo("Authorization forbidden: nope"); + assertThat(e.getSQLState()).isEqualTo("28000"); + }); + } +} diff --git a/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/LibsqlUrlParserPropertyTest.java b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/LibsqlUrlParserPropertyTest.java index 6c9da6a..e73be6b 100644 --- a/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/LibsqlUrlParserPropertyTest.java +++ b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/LibsqlUrlParserPropertyTest.java @@ -73,6 +73,24 @@ void parseNeverThrowsAnUnexpectedExceptionFromProperties() { }); } + @Test + void parseNeverThrowsAnUnexpectedExceptionFromTransactionMode() { + PropertyChecker.customized() + .withIterationCount(ITERATIONS) + .forAll(transactionModeValues(), value -> { + Properties properties = new Properties(); + properties.setProperty("transactionMode", value); + try { + LibsqlUrlParser.parseConnection("jdbc:libsql:http://localhost:8080", properties); + } catch (SQLException expected) { + // A rejected mode is a caller error, not a driver crash. + } catch (RuntimeException unexpected) { + throw new AssertionError("unexpected " + unexpected + " for " + value, unexpected); + } + return true; + }); + } + @Test void metadataUrlIsIdempotent() { PropertyChecker.customized() @@ -97,6 +115,14 @@ private static Generator timeoutValues() { Generator.constant("999999999999999999999999")); } + private static Generator transactionModeValues() { + return Generator.anyOf( + Generator.sampledFrom("deferred", "immediate", "exclusive", "DEFERRED", "Immediate", "EXCLUSIVE"), + Generator.stringsOf(IntDistribution.uniform(0, 12), Generator.charsInRange((char) 0, (char) 127)), + Generator.constant(""), + Generator.constant("fast")); + } + private static Generator arbitraryUrls() { Generator fragments = Generator.anyOf( Generator.constant("jdbc:libsql:"), @@ -110,6 +136,10 @@ private static Generator arbitraryUrls() { Generator.constant("token"), Generator.constant("jwt"), Generator.constant("connectTimeout"), + Generator.constant("transactionMode"), + Generator.constant("deferred"), + Generator.constant("immediate"), + Generator.constant("exclusive"), Generator.constant("user:pass@"), Generator.stringsOf(IntDistribution.uniform(0, 8), Generator.charsInRange((char) 0, (char) 127))); return Generator.listsOf(IntDistribution.uniform(0, 10), fragments) diff --git a/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/LibsqlUrlParserTest.java b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/LibsqlUrlParserTest.java index 76ad967..0090c6b 100644 --- a/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/LibsqlUrlParserTest.java +++ b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/LibsqlUrlParserTest.java @@ -104,6 +104,84 @@ void testTlsKeepsAlreadySecureSchemes() throws SQLException { .isEqualTo("wss://example.com"); } + @Test + void testParseTransactionModeFromTheUrl() throws SQLException { + assertThat(parseConnection("jdbc:libsql:http://localhost:8080?transactionMode=deferred") + .transactionMode()) + .isEqualTo(LibsqlConnection.TransactionMode.DEFERRED); + assertThat(parseConnection("jdbc:libsql:http://localhost:8080?transactionMode=immediate") + .transactionMode()) + .isEqualTo(LibsqlConnection.TransactionMode.IMMEDIATE); + assertThat(parseConnection("jdbc:libsql:http://localhost:8080?transactionMode=exclusive") + .transactionMode()) + .isEqualTo(LibsqlConnection.TransactionMode.EXCLUSIVE); + } + + @Test + void testParseTransactionModeIsCaseInsensitive() throws SQLException { + assertThat(parseConnection("jdbc:libsql:http://localhost:8080?transactionMode=ImMeDiAtE") + .transactionMode()) + .isEqualTo(LibsqlConnection.TransactionMode.IMMEDIATE); + assertThat(parseConnection("jdbc:libsql:http://localhost:8080?transactionMode=EXCLUSIVE") + .transactionMode()) + .isEqualTo(LibsqlConnection.TransactionMode.EXCLUSIVE); + } + + @Test + void testParseTransactionModeDefaultsToDeferred() throws SQLException { + assertThat(parseConnection("jdbc:libsql:http://localhost:8080").transactionMode()) + .isEqualTo(LibsqlConnection.TransactionMode.DEFERRED); + } + + @Test + void testTransactionModeFromTheUrlBeatsProperties() throws SQLException { + Properties properties = new Properties(); + properties.setProperty("transactionMode", "exclusive"); + + assertThat(parseConnection("jdbc:libsql:http://localhost:8080?transactionMode=immediate", properties) + .transactionMode()) + .isEqualTo(LibsqlConnection.TransactionMode.IMMEDIATE); + } + + @Test + void testTransactionModeFallsBackToProperties() throws SQLException { + Properties properties = new Properties(); + properties.setProperty("transactionMode", "EXCLUSIVE"); + + assertThat(parseConnection("jdbc:libsql:http://localhost:8080", properties).transactionMode()) + .isEqualTo(LibsqlConnection.TransactionMode.EXCLUSIVE); + } + + @Test + void testRejectsUnknownTransactionMode() { + assertThatThrownBy(() -> parseConnection("jdbc:libsql:http://localhost:8080?transactionMode=fast")) + .isInstanceOf(SQLException.class) + .hasMessage("Unsupported transactionMode 'fast': expected deferred, immediate, or exclusive"); + + Properties properties = new Properties(); + properties.setProperty("transactionMode", "fast"); + assertThatThrownBy(() -> parseConnection("jdbc:libsql:http://localhost:8080", properties)) + .isInstanceOf(SQLException.class) + .hasMessage("Unsupported transactionMode 'fast': expected deferred, immediate, or exclusive"); + } + + @Test + void testRejectsEmptyTransactionMode() { + assertThatThrownBy(() -> parseConnection("jdbc:libsql:http://localhost:8080?transactionMode=")) + .isInstanceOf(SQLException.class) + .hasMessage("Unsupported transactionMode '': expected deferred, immediate, or exclusive"); + } + + @Test + void testPublicParseReturnsTheParsedClientConfig() throws SQLException { + String url = "jdbc:libsql:http://localhost:8080/dev/tenant?authToken=secretToken" + + "&connectTimeout=2500&transactionMode=immediate"; + + assertThat(LibsqlUrlParser.parse(url, new Properties())) + .isEqualTo(parseConnection(url).client()); + assertThat(LibsqlUrlParser.parse(url, new Properties()).authToken()).isEqualTo("secretToken"); + } + @Test void testParseEncodesWebSocketEncoding() throws SQLException { assertThat(LibsqlUrlParser.parse( @@ -186,4 +264,12 @@ void testMetadataUrlLeavesANonCredentialUrlUnchanged() { String url = "jdbc:sqlite:http://localhost:8080/dev/tenant?connectTimeout=5000"; assertThat(LibsqlUrlParser.metadataUrl(url)).isEqualTo(url); } + + private static ParsedConnection parseConnection(String url) throws SQLException { + return parseConnection(url, new Properties()); + } + + private static ParsedConnection parseConnection(String url, Properties properties) throws SQLException { + return LibsqlUrlParser.parseConnection(url, properties); + } }