From 47c53d0992e625a23cb674b7cbacad3fc14ad9ff Mon Sep 17 00:00:00 2001
From: Magnus Persson <1431685+magnusp@users.noreply.github.com>
Date: Sat, 24 Oct 2026 03:54:20 +0200
Subject: [PATCH 1/6] feat(codec): add Hrana 3 WebSocket Protobuf codec
---
.../hrana/codec/HranaWsProtobufCodec.java | 237 ++++++++++++++++++
1 file changed, 237 insertions(+)
create mode 100644 libsql-hrana-codec/src/main/java/com/github/magnusp/libsql/hrana/codec/HranaWsProtobufCodec.java
diff --git a/libsql-hrana-codec/src/main/java/com/github/magnusp/libsql/hrana/codec/HranaWsProtobufCodec.java b/libsql-hrana-codec/src/main/java/com/github/magnusp/libsql/hrana/codec/HranaWsProtobufCodec.java
new file mode 100644
index 0000000..d02d6a3
--- /dev/null
+++ b/libsql-hrana-codec/src/main/java/com/github/magnusp/libsql/hrana/codec/HranaWsProtobufCodec.java
@@ -0,0 +1,237 @@
+package com.github.magnusp.libsql.hrana.codec;
+
+import com.github.magnusp.libsql.hrana.proto.HranaProto;
+import com.github.magnusp.libsql.hrana.proto.HranaWsProto;
+import com.google.protobuf.ByteString;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Hrana 3 WebSocket message codec for the Protobuf encoding ({@code hrana3-protobuf}
+ * subprotocol).
+ *
+ *
Every client or server message is a complete {@code hrana.ws.ClientMsg} or {@code
+ * hrana.ws.ServerMsg} serialized as one binary WebSocket frame.
+ */
+public final class HranaWsProtobufCodec implements HranaWsCodec {
+
+ @Override
+ public boolean isBinary() {
+ return true;
+ }
+
+ @Override
+ public byte[] serialize(WsClientMsg msg) throws IOException {
+ HranaWsProto.ClientMsg.Builder clientMsg = HranaWsProto.ClientMsg.newBuilder();
+ switch (msg) {
+ case WsClientMsg.Hello hello -> {
+ HranaWsProto.HelloMsg.Builder helloMsg = HranaWsProto.HelloMsg.newBuilder();
+ if (hello.jwt() != null) {
+ helloMsg.setJwt(hello.jwt());
+ }
+ clientMsg.setHello(helloMsg);
+ }
+ case WsClientMsg.Request request -> {
+ HranaWsProto.RequestMsg.Builder requestMsg = HranaWsProto.RequestMsg.newBuilder()
+ .setRequestId(request.requestId());
+ switch (request.request()) {
+ case WsRequest.OpenStream open -> requestMsg.setOpenStream(
+ HranaWsProto.OpenStreamReq.newBuilder()
+ .setStreamId(open.streamId()));
+ case WsRequest.CloseStream close -> requestMsg.setCloseStream(
+ HranaWsProto.CloseStreamReq.newBuilder()
+ .setStreamId(close.streamId()));
+ case WsRequest.Execute execute -> requestMsg.setExecute(
+ HranaWsProto.ExecuteReq.newBuilder()
+ .setStreamId(execute.streamId())
+ .setStmt(toProto(execute.stmt())));
+ case WsRequest.Batch batch -> requestMsg.setBatch(
+ HranaWsProto.BatchReq.newBuilder()
+ .setStreamId(batch.streamId())
+ .setBatch(toProto(batch.batch())));
+ }
+ clientMsg.setRequest(requestMsg);
+ }
+ }
+ return clientMsg.build().toByteArray();
+ }
+
+ @Override
+ public WsServerMsg deserialize(byte[] payload) throws IOException {
+ HranaWsProto.ServerMsg serverMsg = HranaWsProto.ServerMsg.parseFrom(payload);
+ return switch (serverMsg.getMsgCase()) {
+ case HELLO_OK -> WsServerMsg.HelloOk.INSTANCE;
+ case HELLO_ERROR -> new WsServerMsg.HelloError(
+ fromProto(serverMsg.getHelloError().getError()));
+ case RESPONSE_OK -> new WsServerMsg.ResponseOk(
+ serverMsg.getResponseOk().getRequestId(),
+ fromProto(serverMsg.getResponseOk()));
+ case RESPONSE_ERROR -> new WsServerMsg.ResponseError(
+ serverMsg.getResponseError().getRequestId(),
+ fromProto(serverMsg.getResponseError().getError()));
+ case MSG_NOT_SET -> throw new IOException(
+ "Hrana protobuf server message has no msg case");
+ };
+ }
+
+ // -------------------------------------------------------------
+ // Client structures
+ // -------------------------------------------------------------
+
+ private static HranaProto.Stmt toProto(Stmt stmt) {
+ HranaProto.Stmt.Builder out = HranaProto.Stmt.newBuilder();
+ if (stmt.sql() != null) {
+ out.setSql(stmt.sql());
+ }
+ if (stmt.sqlId() != null) {
+ out.setSqlId(stmt.sqlId());
+ }
+ if (stmt.args() != null) {
+ for (Value arg : stmt.args()) {
+ out.addArgs(toProto(arg));
+ }
+ }
+ if (stmt.namedArgs() != null) {
+ for (NamedArg namedArg : stmt.namedArgs()) {
+ out.addNamedArgs(HranaProto.NamedArg.newBuilder()
+ .setName(namedArg.name())
+ .setValue(toProto(namedArg.value())));
+ }
+ }
+ if (stmt.wantRows() != null) {
+ out.setWantRows(stmt.wantRows());
+ }
+ return out.build();
+ }
+
+ private static HranaProto.Value toProto(Value value) {
+ HranaProto.Value.Builder out = HranaProto.Value.newBuilder();
+ switch (value) {
+ case Value.Null ignored -> out.setNull(HranaProto.Value.Null.getDefaultInstance());
+ case Value.Integer integer -> out.setInteger(integer.value());
+ case Value.Float floating -> out.setFloat(floating.value());
+ case Value.Text text -> out.setText(text.value());
+ case Value.Blob blob -> out.setBlob(ByteString.copyFrom(blob.bytes()));
+ }
+ return out.build();
+ }
+
+ private static HranaProto.Batch toProto(Batch batch) {
+ HranaProto.Batch.Builder out = HranaProto.Batch.newBuilder();
+ for (BatchStep step : batch.steps()) {
+ HranaProto.BatchStep.Builder stepMsg = HranaProto.BatchStep.newBuilder()
+ .setStmt(toProto(step.stmt()));
+ if (step.condition() != null) {
+ stepMsg.setCondition(toProto(step.condition()));
+ }
+ out.addSteps(stepMsg);
+ }
+ return out.build();
+ }
+
+ private static HranaProto.BatchCond toProto(BatchCond condition) {
+ HranaProto.BatchCond.Builder out = HranaProto.BatchCond.newBuilder();
+ switch (condition) {
+ case BatchCond.StepOk stepOk -> out.setStepOk(stepOk.step());
+ case BatchCond.StepError stepError -> out.setStepError(stepError.step());
+ case BatchCond.Not not -> out.setNot(toProto(not.cond()));
+ case BatchCond.And and -> out.setAnd(toProtoCondList(and.conds()));
+ case BatchCond.Or or -> out.setOr(toProtoCondList(or.conds()));
+ case BatchCond.IsAutocommit ignored -> out.setIsAutocommit(
+ HranaProto.BatchCond.IsAutocommit.getDefaultInstance());
+ }
+ return out.build();
+ }
+
+ private static HranaProto.BatchCond.CondList toProtoCondList(List conditions) {
+ HranaProto.BatchCond.CondList.Builder out = HranaProto.BatchCond.CondList.newBuilder();
+ for (BatchCond condition : conditions) {
+ out.addConds(toProto(condition));
+ }
+ return out.build();
+ }
+
+ // -------------------------------------------------------------
+ // Server structures
+ // -------------------------------------------------------------
+
+ private static WsResponse fromProto(HranaWsProto.ResponseOkMsg responseMsg) throws IOException {
+ return switch (responseMsg.getResponseCase()) {
+ case OPEN_STREAM -> WsResponse.OpenStream.INSTANCE;
+ case CLOSE_STREAM -> WsResponse.CloseStream.INSTANCE;
+ case EXECUTE -> new WsResponse.Execute(
+ fromProto(responseMsg.getExecute().getResult()));
+ case BATCH -> new WsResponse.Batch(
+ fromProto(responseMsg.getBatch().getResult()));
+ case RESPONSE_NOT_SET -> throw new IOException(
+ "Hrana protobuf response has no response case");
+ default -> throw new IOException("Unsupported Hrana protobuf response: "
+ + responseMsg.getResponseCase());
+ };
+ }
+
+ private static StmtResult fromProto(HranaProto.StmtResult result) throws IOException {
+ List cols = new ArrayList<>(result.getColsCount());
+ for (HranaProto.Col col : result.getColsList()) {
+ cols.add(fromProto(col));
+ }
+ List rows = new ArrayList<>(result.getRowsCount());
+ for (HranaProto.Row row : result.getRowsList()) {
+ rows.add(fromProto(row));
+ }
+ Long lastInsertRowid = result.hasLastInsertRowid()
+ ? result.getLastInsertRowid()
+ : null;
+ return new StmtResult(cols, rows, result.getAffectedRowCount(), lastInsertRowid);
+ }
+
+ private static Col fromProto(HranaProto.Col col) {
+ return new Col(
+ col.hasName() ? col.getName() : null,
+ col.hasDecltype() ? col.getDecltype() : null);
+ }
+
+ private static Row fromProto(HranaProto.Row row) throws IOException {
+ List values = new ArrayList<>(row.getValuesCount());
+ for (HranaProto.Value value : row.getValuesList()) {
+ values.add(fromProto(value));
+ }
+ return new Row(values);
+ }
+
+ private static BatchResult fromProto(HranaProto.BatchResult result) throws IOException {
+ Map stepResults = new LinkedHashMap<>();
+ for (Map.Entry entry
+ : result.getStepResultsMap().entrySet()) {
+ stepResults.put(entry.getKey(), fromProto(entry.getValue()));
+ }
+ Map stepErrors = new LinkedHashMap<>();
+ for (Map.Entry entry
+ : result.getStepErrorsMap().entrySet()) {
+ stepErrors.put(entry.getKey(), fromProto(entry.getValue()));
+ }
+ return new BatchResult(stepResults, stepErrors);
+ }
+
+ private static HranaError fromProto(HranaProto.Error error) {
+ return new HranaError(
+ error.getMessage(),
+ error.hasCode() ? error.getCode() : null);
+ }
+
+ private static Value fromProto(HranaProto.Value value) throws IOException {
+ return switch (value.getValueCase()) {
+ case NULL -> Value.Null.INSTANCE;
+ case INTEGER -> new Value.Integer(value.getInteger());
+ case FLOAT -> new Value.Float(value.getFloat());
+ case TEXT -> new Value.Text(value.getText());
+ case BLOB -> new Value.Blob(value.getBlob().toByteArray());
+ case VALUE_NOT_SET -> throw new IOException(
+ "Hrana protobuf Value has no value case");
+ };
+ }
+}
From da6933315854afb89bcdfec5ec5aa8df7d6f21dc Mon Sep 17 00:00:00 2001
From: Magnus Persson <1431685+magnusp@users.noreply.github.com>
Date: Sat, 24 Oct 2026 03:54:20 +0200
Subject: [PATCH 2/6] feat(client): add encoding config and derive WebSocket
subprotocol from codec
---
.../libsql/client/LibsqlClientConfig.java | 19 ++++++++++++--
.../libsql/client/LibsqlWebSocketClient.java | 26 ++++++++++++-------
2 files changed, 33 insertions(+), 12 deletions(-)
diff --git a/libsql-client/src/main/java/com/github/magnusp/libsql/client/LibsqlClientConfig.java b/libsql-client/src/main/java/com/github/magnusp/libsql/client/LibsqlClientConfig.java
index b9cfe5d..bc911d4 100644
--- a/libsql-client/src/main/java/com/github/magnusp/libsql/client/LibsqlClientConfig.java
+++ b/libsql-client/src/main/java/com/github/magnusp/libsql/client/LibsqlClientConfig.java
@@ -6,12 +6,27 @@ public record LibsqlClientConfig(
String url,
String authToken,
Duration connectTimeout,
- Duration busyTimeout
+ Duration busyTimeout,
+ Encoding encoding
) {
public static final Duration DEFAULT_CONNECT_TIMEOUT = Duration.ofSeconds(10);
public static final Duration DEFAULT_BUSY_TIMEOUT = Duration.ofSeconds(5);
+ /** The wire encoding used for Hrana 3 over WebSocket. HTTP always speaks JSON. */
+ public enum Encoding {
+ JSON,
+ PROTOBUF
+ }
+
+ public LibsqlClientConfig {
+ encoding = encoding == null ? Encoding.JSON : encoding;
+ }
+
+ public LibsqlClientConfig(String url, String authToken, Duration connectTimeout, Duration busyTimeout) {
+ this(url, authToken, connectTimeout, busyTimeout, Encoding.JSON);
+ }
+
public LibsqlClientConfig(String url, String authToken) {
- this(url, authToken, DEFAULT_CONNECT_TIMEOUT, DEFAULT_BUSY_TIMEOUT);
+ this(url, authToken, DEFAULT_CONNECT_TIMEOUT, DEFAULT_BUSY_TIMEOUT, Encoding.JSON);
}
}
diff --git a/libsql-client/src/main/java/com/github/magnusp/libsql/client/LibsqlWebSocketClient.java b/libsql-client/src/main/java/com/github/magnusp/libsql/client/LibsqlWebSocketClient.java
index c2afe0b..94a1acb 100644
--- a/libsql-client/src/main/java/com/github/magnusp/libsql/client/LibsqlWebSocketClient.java
+++ b/libsql-client/src/main/java/com/github/magnusp/libsql/client/LibsqlWebSocketClient.java
@@ -40,7 +40,10 @@
import java.util.concurrent.atomic.AtomicInteger;
/**
- * Hrana 3 client over a WebSocket, speaking the JSON encoding ({@code hrana3} subprotocol).
+ * Hrana 3 client over a WebSocket.
+ *
+ * The supplied {@link HranaWsCodec} selects both the wire encoding and the subprotocol: {@code
+ * hrana3} for JSON text frames and {@code hrana3-protobuf} for Protobuf binary frames.
*
*
One WebSocket connection multiplexes any number of streams. Requests are matched to responses
* by {@code request_id}, so the client may send a whole one-shot sequence (open, execute, close)
@@ -58,7 +61,8 @@
*/
public class LibsqlWebSocketClient implements LibsqlClient {
- private static final String SUBPROTOCOL = "hrana3";
+ private static final String SUBPROTOCOL_JSON = "hrana3";
+ private static final String SUBPROTOCOL_PROTOBUF = "hrana3-protobuf";
private static final String WEBSOCKET_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
private static final int OP_CONTINUATION = 0x0;
@@ -73,6 +77,7 @@ public class LibsqlWebSocketClient implements LibsqlClient {
private final LibsqlClientConfig config;
private final HranaWsCodec codec;
+ private final String subprotocol;
private final Duration timeout;
private final Transport transport;
@@ -97,7 +102,8 @@ public LibsqlWebSocketClient(LibsqlClientConfig config, HranaWsCodec codec) {
this.timeout = config.connectTimeout() != null
? config.connectTimeout()
: LibsqlClientConfig.DEFAULT_CONNECT_TIMEOUT;
- this.transport = new Transport(URI.create(config.url()), timeout);
+ this.subprotocol = codec.isBinary() ? SUBPROTOCOL_PROTOBUF : SUBPROTOCOL_JSON;
+ this.transport = new Transport(URI.create(config.url()), timeout, subprotocol);
try {
send(new WsClientMsg.Hello(authToken()));
@@ -359,14 +365,14 @@ private final class Transport {
private volatile boolean open = true;
- private Transport(URI uri, Duration timeout) {
+ private Transport(URI uri, Duration timeout, String subprotocol) {
Socket connected = null;
try {
connected = connect(uri, timeout);
this.socket = connected;
this.in = connected.getInputStream();
this.out = connected.getOutputStream();
- handshake(uri, in, out, timeout);
+ handshake(uri, in, out, timeout, subprotocol);
} catch (IOException e) {
closeQuietly(connected);
throw new LibsqlException(
@@ -583,8 +589,8 @@ private static Socket connect(URI uri, Duration timeout) throws IOException {
}
}
- private static void handshake(URI uri, InputStream in, OutputStream out, Duration timeout)
- throws IOException {
+ private static void handshake(URI uri, InputStream in, OutputStream out, Duration timeout,
+ String subprotocol) throws IOException {
String key = createKey();
String expectedAccept = acceptKey(key);
String path = uri.getRawPath() == null || uri.getRawPath().isEmpty() ? "/" : uri.getRawPath();
@@ -602,7 +608,7 @@ private static void handshake(URI uri, InputStream in, OutputStream out, Duratio
.append("Connection: Upgrade\r\n")
.append("Sec-WebSocket-Key: ").append(key).append("\r\n")
.append("Sec-WebSocket-Version: 13\r\n")
- .append("Sec-WebSocket-Protocol: ").append(SUBPROTOCOL).append("\r\n")
+ .append("Sec-WebSocket-Protocol: ").append(subprotocol).append("\r\n")
.append("\r\n");
out.write(request.toString().getBytes(StandardCharsets.US_ASCII));
out.flush();
@@ -627,8 +633,8 @@ private static void handshake(URI uri, InputStream in, OutputStream out, Duratio
throw new IOException("WebSocket upgrade response has an invalid Sec-WebSocket-Accept");
}
String negotiated = headers.get("sec-websocket-protocol");
- if (!SUBPROTOCOL.equals(negotiated)) {
- throw new IOException("Server did not negotiate subprotocol " + SUBPROTOCOL
+ if (!subprotocol.equals(negotiated)) {
+ throw new IOException("Server did not negotiate subprotocol " + subprotocol
+ " (got " + negotiated + ")");
}
}
From 3a489c2812bbaf1e94ed191f15754ee98425358c Mon Sep 17 00:00:00 2001
From: Magnus Persson <1431685+magnusp@users.noreply.github.com>
Date: Sat, 24 Oct 2026 03:54:20 +0200
Subject: [PATCH 3/6] feat(jdbc): route encoding=protobuf URLs to the WebSocket
Protobuf codec
---
.../magnusp/libsql/jdbc/LibsqlDriver.java | 15 +++++++++++---
.../magnusp/libsql/jdbc/LibsqlUrlParser.java | 20 ++++++++++++++++++-
2 files changed, 31 insertions(+), 4 deletions(-)
diff --git a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlDriver.java b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlDriver.java
index c13dc11..b49431b 100644
--- a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlDriver.java
+++ b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlDriver.java
@@ -4,6 +4,9 @@
import com.github.magnusp.libsql.client.LibsqlClientConfig;
import com.github.magnusp.libsql.client.LibsqlHttpClient;
import com.github.magnusp.libsql.client.LibsqlWebSocketClient;
+import com.github.magnusp.libsql.hrana.codec.DirectHranaJsonCodec;
+import com.github.magnusp.libsql.hrana.codec.HranaWsCodec;
+import com.github.magnusp.libsql.hrana.codec.HranaWsProtobufCodec;
import java.sql.Connection;
import java.sql.Driver;
@@ -33,9 +36,15 @@ public Connection connect(String url, Properties info) throws SQLException {
return null;
}
LibsqlClientConfig config = LibsqlUrlParser.parse(url, info);
- LibsqlClient client = isWebSocketUrl(config.url())
- ? new LibsqlWebSocketClient(config)
- : new LibsqlHttpClient(config);
+ LibsqlClient client;
+ if (isWebSocketUrl(config.url())) {
+ HranaWsCodec codec = config.encoding() == LibsqlClientConfig.Encoding.PROTOBUF
+ ? new HranaWsProtobufCodec()
+ : new DirectHranaJsonCodec();
+ client = new LibsqlWebSocketClient(config, codec);
+ } else {
+ client = new LibsqlHttpClient(config);
+ }
return new LibsqlConnection(config, client);
}
diff --git a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlUrlParser.java b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlUrlParser.java
index 2dbe019..ed5ee09 100644
--- a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlUrlParser.java
+++ b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlUrlParser.java
@@ -6,6 +6,7 @@
import java.sql.SQLException;
import java.time.Duration;
import java.util.HashMap;
+import java.util.Locale;
import java.util.Map;
import java.util.Properties;
@@ -76,6 +77,22 @@ public static LibsqlClientConfig parse(String url, Properties info) throws SQLEx
};
}
+ // Encoding selection. JSON stays the default; Protobuf exists only for WebSocket.
+ LibsqlClientConfig.Encoding encoding = LibsqlClientConfig.Encoding.JSON;
+ String encodingParam = queryParams.get("encoding");
+ if (encodingParam != null) {
+ switch (encodingParam.toLowerCase(Locale.ROOT)) {
+ case "json" -> encoding = LibsqlClientConfig.Encoding.JSON;
+ case "protobuf", "proto" -> encoding = LibsqlClientConfig.Encoding.PROTOBUF;
+ default -> throw new SQLException(
+ "Unsupported encoding '" + encodingParam + "': expected json or protobuf");
+ }
+ }
+ if (encoding == LibsqlClientConfig.Encoding.PROTOBUF
+ && !"ws".equals(scheme) && !"wss".equals(scheme)) {
+ throw new SQLException("encoding=protobuf is only supported for ws/wss URLs");
+ }
+
// Timeouts
Duration connectTimeout = LibsqlClientConfig.DEFAULT_CONNECT_TIMEOUT;
if (queryParams.containsKey("connectTimeout") || queryParams.containsKey("socketTimeout")) {
@@ -102,7 +119,8 @@ public static LibsqlClientConfig parse(String url, Properties info) throws SQLEx
}
endpointUrl.append(path);
- return new LibsqlClientConfig(endpointUrl.toString(), authToken, connectTimeout, busyTimeout);
+ return new LibsqlClientConfig(
+ endpointUrl.toString(), authToken, connectTimeout, busyTimeout, encoding);
}
private static Map parseQuery(String query) {
From 4f52d9e7cf6182c02062b39f8a4619bcfc0ca39b Mon Sep 17 00:00:00 2001
From: Magnus Persson <1431685+magnusp@users.noreply.github.com>
Date: Sat, 24 Oct 2026 03:54:20 +0200
Subject: [PATCH 4/6] test(codec): cover Hrana WebSocket Protobuf encode and
decode
---
.../hrana/codec/HranaWsProtobufCodecTest.java | 367 ++++++++++++++++++
1 file changed, 367 insertions(+)
create mode 100644 libsql-hrana-codec/src/test/java/com/github/magnusp/libsql/hrana/codec/HranaWsProtobufCodecTest.java
diff --git a/libsql-hrana-codec/src/test/java/com/github/magnusp/libsql/hrana/codec/HranaWsProtobufCodecTest.java b/libsql-hrana-codec/src/test/java/com/github/magnusp/libsql/hrana/codec/HranaWsProtobufCodecTest.java
new file mode 100644
index 0000000..6fd96b6
--- /dev/null
+++ b/libsql-hrana-codec/src/test/java/com/github/magnusp/libsql/hrana/codec/HranaWsProtobufCodecTest.java
@@ -0,0 +1,367 @@
+package com.github.magnusp.libsql.hrana.codec;
+
+import com.github.magnusp.libsql.hrana.proto.HranaProto;
+import com.github.magnusp.libsql.hrana.proto.HranaWsProto;
+import com.google.protobuf.ByteString;
+
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class HranaWsProtobufCodecTest {
+
+ private final HranaWsProtobufCodec codec = new HranaWsProtobufCodec();
+
+ private HranaWsProto.ClientMsg serialize(WsClientMsg msg) throws IOException {
+ return HranaWsProto.ClientMsg.parseFrom(codec.serialize(msg));
+ }
+
+ private WsServerMsg deserialize(HranaWsProto.ServerMsg msg) throws IOException {
+ return codec.deserialize(msg.toByteArray());
+ }
+
+ private static ByteString bytes(int... values) {
+ byte[] bytes = new byte[values.length];
+ for (int i = 0; i < values.length; i++) {
+ bytes[i] = (byte) values[i];
+ }
+ return ByteString.copyFrom(bytes);
+ }
+
+ @Test
+ void codecUsesBinaryFrames() {
+ assertThat(codec).isInstanceOf(HranaWsCodec.class);
+ assertThat(codec.isBinary()).isTrue();
+ }
+
+ @Test
+ void serializesHelloWithJwt() throws IOException {
+ HranaWsProto.ClientMsg msg = serialize(new WsClientMsg.Hello("jwt-token"));
+
+ assertThat(msg.getMsgCase()).isEqualTo(HranaWsProto.ClientMsg.MsgCase.HELLO);
+ assertThat(msg.getHello().hasJwt()).isTrue();
+ assertThat(msg.getHello().getJwt()).isEqualTo("jwt-token");
+ }
+
+ @Test
+ void serializesHelloWithoutJwtWhenNull() throws IOException {
+ HranaWsProto.ClientMsg msg = serialize(new WsClientMsg.Hello(null));
+
+ assertThat(msg.getMsgCase()).isEqualTo(HranaWsProto.ClientMsg.MsgCase.HELLO);
+ assertThat(msg.getHello().hasJwt()).isFalse();
+ }
+
+ @Test
+ void serializesOpenAndCloseStreamRequests() throws IOException {
+ HranaWsProto.RequestMsg open = serialize(
+ new WsClientMsg.Request(7, new WsRequest.OpenStream(3))).getRequest();
+ assertThat(open.getRequestId()).isEqualTo(7);
+ assertThat(open.getRequestCase()).isEqualTo(HranaWsProto.RequestMsg.RequestCase.OPEN_STREAM);
+ assertThat(open.getOpenStream().getStreamId()).isEqualTo(3);
+
+ HranaWsProto.RequestMsg close = serialize(
+ new WsClientMsg.Request(8, new WsRequest.CloseStream(4))).getRequest();
+ assertThat(close.getRequestId()).isEqualTo(8);
+ assertThat(close.getRequestCase()).isEqualTo(HranaWsProto.RequestMsg.RequestCase.CLOSE_STREAM);
+ assertThat(close.getCloseStream().getStreamId()).isEqualTo(4);
+ }
+
+ @Test
+ void serializesExecuteWithEveryValueTypeAndBothArgStyles() throws IOException {
+ Stmt stmt = new Stmt(
+ "SELECT :a",
+ 12,
+ List.of(
+ new Value.Integer(Long.MIN_VALUE),
+ new Value.Integer(Long.MAX_VALUE),
+ new Value.Float(1.5),
+ new Value.Text("h\u00e9llo \u4e16\u754c \ud83d\ude00"),
+ Value.Null.INSTANCE,
+ new Value.Blob(new byte[0]),
+ new Value.Blob(new byte[] {1, 2, 3})),
+ List.of(
+ new NamedArg(":a", new Value.Text("named")),
+ new NamedArg(":b", new Value.Integer(7))),
+ false);
+
+ HranaWsProto.RequestMsg request = serialize(
+ new WsClientMsg.Request(9, new WsRequest.Execute(5, stmt))).getRequest();
+
+ assertThat(request.getRequestCase()).isEqualTo(HranaWsProto.RequestMsg.RequestCase.EXECUTE);
+ assertThat(request.getExecute().getStreamId()).isEqualTo(5);
+
+ HranaProto.Stmt protoStmt = request.getExecute().getStmt();
+ assertThat(protoStmt.hasSql()).isTrue();
+ assertThat(protoStmt.getSql()).isEqualTo("SELECT :a");
+ assertThat(protoStmt.hasSqlId()).isTrue();
+ assertThat(protoStmt.getSqlId()).isEqualTo(12);
+ assertThat(protoStmt.hasWantRows()).isTrue();
+ assertThat(protoStmt.getWantRows()).isFalse();
+ assertThat(protoStmt.getArgsCount()).isEqualTo(7);
+
+ assertThat(protoStmt.getArgs(0).getValueCase())
+ .isEqualTo(HranaProto.Value.ValueCase.INTEGER);
+ assertThat(protoStmt.getArgs(0).getInteger()).isEqualTo(Long.MIN_VALUE);
+ assertThat(protoStmt.getArgs(1).getInteger()).isEqualTo(Long.MAX_VALUE);
+ assertThat(protoStmt.getArgs(2).getValueCase())
+ .isEqualTo(HranaProto.Value.ValueCase.FLOAT);
+ assertThat(protoStmt.getArgs(2).getFloat()).isEqualTo(1.5);
+ assertThat(protoStmt.getArgs(3).getText()).isEqualTo("h\u00e9llo \u4e16\u754c \ud83d\ude00");
+ assertThat(protoStmt.getArgs(4).getValueCase())
+ .isEqualTo(HranaProto.Value.ValueCase.NULL);
+ assertThat(protoStmt.getArgs(5).getValueCase())
+ .isEqualTo(HranaProto.Value.ValueCase.BLOB);
+ assertThat(protoStmt.getArgs(5).getBlob()).isEqualTo(ByteString.EMPTY);
+ assertThat(protoStmt.getArgs(6).getBlob()).isEqualTo(bytes(1, 2, 3));
+
+ assertThat(protoStmt.getNamedArgsCount()).isEqualTo(2);
+ assertThat(protoStmt.getNamedArgs(0).getName()).isEqualTo(":a");
+ assertThat(protoStmt.getNamedArgs(0).getValue().getText()).isEqualTo("named");
+ assertThat(protoStmt.getNamedArgs(1).getName()).isEqualTo(":b");
+ assertThat(protoStmt.getNamedArgs(1).getValue().getInteger()).isEqualTo(7);
+ }
+
+ @Test
+ void serializesBatchWithEveryConditionAndAnUnconditionalStep() throws IOException {
+ Batch batch = new Batch(List.of(
+ new BatchStep(new BatchCond.StepOk(0), new Stmt("BEGIN")),
+ new BatchStep(new BatchCond.StepError(1), new Stmt("ROLLBACK")),
+ new BatchStep(new BatchCond.Not(new BatchCond.StepOk(2)), new Stmt("COMMIT")),
+ new BatchStep(new BatchCond.And(List.of(
+ new BatchCond.StepOk(0), new BatchCond.StepOk(1))), new Stmt("INSERT 1")),
+ new BatchStep(new BatchCond.Or(List.of(
+ new BatchCond.StepError(0), BatchCond.IsAutocommit.INSTANCE)), new Stmt("INSERT 2")),
+ new BatchStep(BatchCond.IsAutocommit.INSTANCE, new Stmt("INSERT 3")),
+ new BatchStep(new Stmt("SELECT 1"))));
+
+ HranaWsProto.RequestMsg request = serialize(
+ new WsClientMsg.Request(10, new WsRequest.Batch(6, batch))).getRequest();
+
+ assertThat(request.getRequestCase()).isEqualTo(HranaWsProto.RequestMsg.RequestCase.BATCH);
+ assertThat(request.getBatch().getStreamId()).isEqualTo(6);
+
+ HranaProto.Batch protoBatch = request.getBatch().getBatch();
+ assertThat(protoBatch.getStepsCount()).isEqualTo(7);
+ assertThat(protoBatch.getSteps(0).getStmt().getSql()).isEqualTo("BEGIN");
+ assertThat(protoBatch.getSteps(6).hasCondition()).isFalse();
+
+ HranaProto.BatchCond stepOk = protoBatch.getSteps(0).getCondition();
+ assertThat(stepOk.getCondCase()).isEqualTo(HranaProto.BatchCond.CondCase.STEP_OK);
+ assertThat(stepOk.getStepOk()).isEqualTo(0);
+
+ HranaProto.BatchCond stepError = protoBatch.getSteps(1).getCondition();
+ assertThat(stepError.getCondCase()).isEqualTo(HranaProto.BatchCond.CondCase.STEP_ERROR);
+ assertThat(stepError.getStepError()).isEqualTo(1);
+
+ HranaProto.BatchCond not = protoBatch.getSteps(2).getCondition();
+ assertThat(not.getCondCase()).isEqualTo(HranaProto.BatchCond.CondCase.NOT);
+ assertThat(not.getNot().getCondCase()).isEqualTo(HranaProto.BatchCond.CondCase.STEP_OK);
+
+ HranaProto.BatchCond and = protoBatch.getSteps(3).getCondition();
+ assertThat(and.getCondCase()).isEqualTo(HranaProto.BatchCond.CondCase.AND);
+ assertThat(and.getAnd().getCondsCount()).isEqualTo(2);
+ assertThat(and.getAnd().getConds(1).getStepOk()).isEqualTo(1);
+
+ HranaProto.BatchCond or = protoBatch.getSteps(4).getCondition();
+ assertThat(or.getCondCase()).isEqualTo(HranaProto.BatchCond.CondCase.OR);
+ assertThat(or.getOr().getCondsCount()).isEqualTo(2);
+ assertThat(or.getOr().getConds(0).getCondCase())
+ .isEqualTo(HranaProto.BatchCond.CondCase.STEP_ERROR);
+ assertThat(or.getOr().getConds(1).getCondCase())
+ .isEqualTo(HranaProto.BatchCond.CondCase.IS_AUTOCOMMIT);
+
+ assertThat(protoBatch.getSteps(5).getCondition().getCondCase())
+ .isEqualTo(HranaProto.BatchCond.CondCase.IS_AUTOCOMMIT);
+ }
+
+ @Test
+ void deserializesHelloOk() throws IOException {
+ WsServerMsg msg = deserialize(HranaWsProto.ServerMsg.newBuilder()
+ .setHelloOk(HranaWsProto.HelloOkMsg.getDefaultInstance())
+ .build());
+
+ assertThat(msg).isEqualTo(WsServerMsg.HelloOk.INSTANCE);
+ }
+
+ @Test
+ void deserializesHelloErrorWithAndWithoutCode() throws IOException {
+ WsServerMsg withCode = deserialize(HranaWsProto.ServerMsg.newBuilder()
+ .setHelloError(HranaWsProto.HelloErrorMsg.newBuilder()
+ .setError(HranaProto.Error.newBuilder()
+ .setMessage("bad jwt")
+ .setCode("AUTH")))
+ .build());
+ assertThat(withCode).isEqualTo(
+ new WsServerMsg.HelloError(new HranaError("bad jwt", "AUTH")));
+
+ WsServerMsg withoutCode = deserialize(HranaWsProto.ServerMsg.newBuilder()
+ .setHelloError(HranaWsProto.HelloErrorMsg.newBuilder()
+ .setError(HranaProto.Error.newBuilder().setMessage("bad jwt")))
+ .build());
+ assertThat(withoutCode).isEqualTo(
+ new WsServerMsg.HelloError(new HranaError("bad jwt", null)));
+ }
+
+ @Test
+ void deserializesOpenAndCloseStreamResponses() throws IOException {
+ assertThat(deserialize(HranaWsProto.ServerMsg.newBuilder()
+ .setResponseOk(HranaWsProto.ResponseOkMsg.newBuilder()
+ .setRequestId(1)
+ .setOpenStream(HranaWsProto.OpenStreamResp.getDefaultInstance()))
+ .build()))
+ .isEqualTo(new WsServerMsg.ResponseOk(1, WsResponse.OpenStream.INSTANCE));
+
+ assertThat(deserialize(HranaWsProto.ServerMsg.newBuilder()
+ .setResponseOk(HranaWsProto.ResponseOkMsg.newBuilder()
+ .setRequestId(2)
+ .setCloseStream(HranaWsProto.CloseStreamResp.getDefaultInstance()))
+ .build()))
+ .isEqualTo(new WsServerMsg.ResponseOk(2, WsResponse.CloseStream.INSTANCE));
+ }
+
+ @Test
+ void deserializesExecuteResponseWithEveryValueType() throws IOException {
+ HranaProto.StmtResult result = HranaProto.StmtResult.newBuilder()
+ .addCols(HranaProto.Col.newBuilder().setName("id").setDecltype("INTEGER"))
+ .addCols(HranaProto.Col.newBuilder().setName("value"))
+ .addRows(HranaProto.Row.newBuilder()
+ .addValues(HranaProto.Value.newBuilder().setNull(
+ HranaProto.Value.Null.getDefaultInstance()))
+ .addValues(HranaProto.Value.newBuilder().setInteger(Long.MIN_VALUE))
+ .addValues(HranaProto.Value.newBuilder().setInteger(Long.MAX_VALUE))
+ .addValues(HranaProto.Value.newBuilder().setFloat(1.5))
+ .addValues(HranaProto.Value.newBuilder().setText(
+ "h\u00e9llo \u4e16\u754c \ud83d\ude00")))
+ .addRows(HranaProto.Row.newBuilder()
+ .addValues(HranaProto.Value.newBuilder().setBlob(ByteString.EMPTY))
+ .addValues(HranaProto.Value.newBuilder().setBlob(bytes(1, 2, 3))))
+ .setAffectedRowCount(2)
+ .build();
+
+ WsServerMsg msg = deserialize(HranaWsProto.ServerMsg.newBuilder()
+ .setResponseOk(HranaWsProto.ResponseOkMsg.newBuilder()
+ .setRequestId(11)
+ .setExecute(HranaWsProto.ExecuteResp.newBuilder().setResult(result)))
+ .build());
+
+ StmtResult expected = new StmtResult(
+ List.of(new Col("id", "INTEGER"), new Col("value", null)),
+ List.of(
+ new Row(List.of(
+ Value.Null.INSTANCE,
+ new Value.Integer(Long.MIN_VALUE),
+ new Value.Integer(Long.MAX_VALUE),
+ new Value.Float(1.5),
+ new Value.Text("h\u00e9llo \u4e16\u754c \ud83d\ude00"))),
+ new Row(List.of(
+ new Value.Blob(new byte[0]),
+ new Value.Blob(new byte[] {1, 2, 3})))),
+ 2,
+ null);
+
+ assertThat(msg).isEqualTo(new WsServerMsg.ResponseOk(11, new WsResponse.Execute(expected)));
+ }
+
+ @Test
+ void deserializesExecuteResponseWithLastInsertRowid() throws IOException {
+ HranaProto.StmtResult result = HranaProto.StmtResult.newBuilder()
+ .setAffectedRowCount(1)
+ .setLastInsertRowid(Long.MIN_VALUE)
+ .build();
+
+ WsServerMsg msg = deserialize(HranaWsProto.ServerMsg.newBuilder()
+ .setResponseOk(HranaWsProto.ResponseOkMsg.newBuilder()
+ .setRequestId(12)
+ .setExecute(HranaWsProto.ExecuteResp.newBuilder().setResult(result)))
+ .build());
+
+ assertThat(msg).isEqualTo(new WsServerMsg.ResponseOk(
+ 12,
+ new WsResponse.Execute(
+ new StmtResult(List.of(), List.of(), 1, Long.MIN_VALUE))));
+ }
+
+ @Test
+ void deserializesBatchResponseMaps() throws IOException {
+ HranaProto.BatchResult result = HranaProto.BatchResult.newBuilder()
+ .putStepResults(0, HranaProto.StmtResult.newBuilder()
+ .setAffectedRowCount(1)
+ .setLastInsertRowid(42)
+ .build())
+ .putStepErrors(1, HranaProto.Error.newBuilder()
+ .setMessage("UNIQUE constraint failed: t.id")
+ .setCode("SQLITE_CONSTRAINT")
+ .build())
+ .build();
+
+ WsServerMsg msg = deserialize(HranaWsProto.ServerMsg.newBuilder()
+ .setResponseOk(HranaWsProto.ResponseOkMsg.newBuilder()
+ .setRequestId(13)
+ .setBatch(HranaWsProto.BatchResp.newBuilder().setResult(result)))
+ .build());
+
+ BatchResult expected = new BatchResult(
+ Map.of(0, new StmtResult(List.of(), List.of(), 1, 42L)),
+ Map.of(1, new HranaError("UNIQUE constraint failed: t.id", "SQLITE_CONSTRAINT")));
+
+ assertThat(msg).isEqualTo(new WsServerMsg.ResponseOk(13, new WsResponse.Batch(expected)));
+ }
+
+ @Test
+ void deserializesResponseError() throws IOException {
+ WsServerMsg msg = deserialize(HranaWsProto.ServerMsg.newBuilder()
+ .setResponseError(HranaWsProto.ResponseErrorMsg.newBuilder()
+ .setRequestId(14)
+ .setError(HranaProto.Error.newBuilder()
+ .setMessage("no such table: t")
+ .setCode("SQLITE_ERROR")))
+ .build());
+
+ assertThat(msg).isEqualTo(new WsServerMsg.ResponseError(
+ 14, new HranaError("no such table: t", "SQLITE_ERROR")));
+ }
+
+ @Test
+ void rejectsMessagesWithoutAPayload() {
+ HranaWsProto.ServerMsg empty = HranaWsProto.ServerMsg.getDefaultInstance();
+ assertThatThrownBy(() -> deserialize(empty))
+ .isInstanceOf(IOException.class)
+ .hasMessageContaining("msg case");
+
+ HranaProto.Value valueWithoutCase = HranaProto.Value.getDefaultInstance();
+ HranaProto.StmtResult result = HranaProto.StmtResult.newBuilder()
+ .addRows(HranaProto.Row.newBuilder().addValues(valueWithoutCase))
+ .build();
+ HranaWsProto.ServerMsg response = HranaWsProto.ServerMsg.newBuilder()
+ .setResponseOk(HranaWsProto.ResponseOkMsg.newBuilder()
+ .setRequestId(15)
+ .setExecute(HranaWsProto.ExecuteResp.newBuilder().setResult(result)))
+ .build();
+ assertThatThrownBy(() -> deserialize(response))
+ .isInstanceOf(IOException.class)
+ .hasMessageContaining("value case");
+ }
+
+ @Test
+ void serializesUtf8TextWithoutReplacement() throws IOException {
+ String unicode = "gr\u00fc\u00dfe \u65e5\u672c\u8a9e \u20ac";
+ HranaWsProto.ClientMsg msg = serialize(
+ new WsClientMsg.Request(16, new WsRequest.Execute(1, new Stmt(
+ "SELECT ?",
+ null,
+ List.of(new Value.Text(unicode)),
+ List.of(),
+ true))));
+
+ String decoded = msg.getRequest().getExecute().getStmt().getArgs(0).getText();
+ assertThat(decoded).isEqualTo(unicode);
+ assertThat(decoded.getBytes(StandardCharsets.UTF_8))
+ .isEqualTo(unicode.getBytes(StandardCharsets.UTF_8));
+ }
+}
From fe5bc461cf2b1945760e5aa920ceb8a5df3fcfe7 Mon Sep 17 00:00:00 2001
From: Magnus Persson <1431685+magnusp@users.noreply.github.com>
Date: Sat, 24 Oct 2026 03:54:20 +0200
Subject: [PATCH 5/6] test(jdbc): cover encoding URL parsing and Protobuf
transport validation
---
.../libsql/jdbc/LibsqlUrlParserTest.java | 40 +++++++++++++++++++
1 file changed, 40 insertions(+)
diff --git a/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlUrlParserTest.java b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlUrlParserTest.java
index d295da9..8ac8070 100644
--- a/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlUrlParserTest.java
+++ b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlUrlParserTest.java
@@ -7,6 +7,7 @@
import java.util.Properties;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
public class LibsqlUrlParserTest {
@@ -69,6 +70,45 @@ void testTlsKeepsAlreadySecureSchemes() throws SQLException {
.isEqualTo("wss://example.com");
}
+ @Test
+ void testParseEncodesWebSocketEncoding() throws SQLException {
+ assertThat(LibsqlUrlParser.parse(
+ "jdbc:libsql:ws://localhost:8080?encoding=protobuf", new Properties())
+ .encoding())
+ .isEqualTo(LibsqlClientConfig.Encoding.PROTOBUF);
+ assertThat(LibsqlUrlParser.parse(
+ "jdbc:libsql:wss://example.com?encoding=PROTO", new Properties())
+ .encoding())
+ .isEqualTo(LibsqlClientConfig.Encoding.PROTOBUF);
+ assertThat(LibsqlUrlParser.parse(
+ "jdbc:libsql:ws://localhost:8080?encoding=json", new Properties())
+ .encoding())
+ .isEqualTo(LibsqlClientConfig.Encoding.JSON);
+ assertThat(LibsqlUrlParser.parse("jdbc:libsql:ws://localhost:8080", new Properties())
+ .encoding())
+ .isEqualTo(LibsqlClientConfig.Encoding.JSON);
+ }
+
+ @Test
+ void testRejectsUnknownEncoding() {
+ assertThatThrownBy(() -> LibsqlUrlParser.parse(
+ "jdbc:libsql:ws://localhost:8080?encoding=msgpack", new Properties()))
+ .isInstanceOf(SQLException.class)
+ .hasMessageContaining("msgpack");
+ }
+
+ @Test
+ void testRejectsProtobufOnHttpTransport() {
+ assertThatThrownBy(() -> LibsqlUrlParser.parse(
+ "jdbc:libsql:http://localhost:8080?encoding=protobuf", new Properties()))
+ .isInstanceOf(SQLException.class)
+ .hasMessage("encoding=protobuf is only supported for ws/wss URLs");
+ assertThatThrownBy(() -> LibsqlUrlParser.parse(
+ "jdbc:libsql:https://example.com?encoding=protobuf", new Properties()))
+ .isInstanceOf(SQLException.class)
+ .hasMessage("encoding=protobuf is only supported for ws/wss URLs");
+ }
+
@Test
void testAcceptsWebSocketUrlForms() {
assertThat(LibsqlUrlParser.acceptsUrl("jdbc:libsql:ws://localhost:8080")).isTrue();
From f5e0b41cfbc48bf1cf5fd2c6fc72f1f8924818c4 Mon Sep 17 00:00:00 2001
From: Magnus Persson <1431685+magnusp@users.noreply.github.com>
Date: Sat, 24 Oct 2026 03:54:20 +0200
Subject: [PATCH 6/6] test(itest): cover JDBC over the Protobuf WebSocket
transport
---
.../itest/WebSocketProtobufTransportIT.java | 86 +++++++++++++++++++
1 file changed, 86 insertions(+)
create mode 100644 libsql-itests/src/test/java/com/github/magnusp/libsql/itest/WebSocketProtobufTransportIT.java
diff --git a/libsql-itests/src/test/java/com/github/magnusp/libsql/itest/WebSocketProtobufTransportIT.java b/libsql-itests/src/test/java/com/github/magnusp/libsql/itest/WebSocketProtobufTransportIT.java
new file mode 100644
index 0000000..1d82ff3
--- /dev/null
+++ b/libsql-itests/src/test/java/com/github/magnusp/libsql/itest/WebSocketProtobufTransportIT.java
@@ -0,0 +1,86 @@
+package com.github.magnusp.libsql.itest;
+
+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.Statement;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Drives the JDBC driver over Hrana 3 Protobuf on a WebSocket ({@code hrana3-protobuf}
+ * subprotocol). The observable JDBC behavior must match the JSON transport.
+ */
+public class WebSocketProtobufTransportIT extends SqldContainerBase {
+
+ private static Connection connect() throws SQLException {
+ return DriverManager.getConnection(
+ "jdbc:libsql:" + getWebSocketUrl() + "?encoding=protobuf");
+ }
+
+ @Test
+ void selectReturnsTypedValuesIncluding64BitIntegerAndBlob() throws SQLException {
+ try (Connection connection = connect();
+ Statement statement = connection.createStatement();
+ ResultSet resultSet = statement.executeQuery(
+ "SELECT 9007199254740993 AS big, -9223372036854775808 AS min,"
+ + " 1.5 AS f, 'hello' AS t, x'DEADBEEF' AS b, NULL AS n")) {
+ assertThat(resultSet.next()).isTrue();
+ assertThat(resultSet.getLong("big")).isEqualTo(9007199254740993L);
+ assertThat(resultSet.getLong("min")).isEqualTo(Long.MIN_VALUE);
+ assertThat(resultSet.getDouble("f")).isEqualTo(1.5);
+ assertThat(resultSet.getString("t")).isEqualTo("hello");
+ assertThat(resultSet.getBytes("b")).containsExactly(0xDE, 0xAD, 0xBE, 0xEF);
+ assertThat(resultSet.getObject("n")).isNull();
+ assertThat(resultSet.next()).isFalse();
+ }
+ }
+
+ @Test
+ void transactionCommitsAndRollsBackOnOneStream() throws SQLException {
+ try (Connection connection = connect()) {
+ try (Statement statement = connection.createStatement()) {
+ statement.execute("CREATE TABLE ws_proto_tx (id INTEGER PRIMARY KEY, name TEXT)");
+ }
+
+ connection.setAutoCommit(false);
+ try (Statement statement = connection.createStatement()) {
+ statement.execute("INSERT INTO ws_proto_tx VALUES (1, 'committed')");
+ }
+ connection.commit();
+
+ try (Statement statement = connection.createStatement()) {
+ statement.execute("INSERT INTO ws_proto_tx VALUES (2, 'rolled back')");
+ }
+ connection.rollback();
+ connection.setAutoCommit(true);
+
+ try (Statement statement = connection.createStatement();
+ ResultSet resultSet = statement.executeQuery(
+ "SELECT id, name FROM ws_proto_tx ORDER BY id")) {
+ assertThat(resultSet.next()).isTrue();
+ assertThat(resultSet.getInt(1)).isEqualTo(1);
+ assertThat(resultSet.getString(2)).isEqualTo("committed");
+ assertThat(resultSet.next()).isFalse();
+ }
+ }
+ }
+
+ @Test
+ void failingStatementMapsToSqlExceptionAndLeavesTheConnectionUsable() throws SQLException {
+ try (Connection connection = connect(); Statement statement = connection.createStatement()) {
+ assertThatThrownBy(() -> statement.executeQuery("SELECT * FROM ws_proto_missing_table"))
+ .isInstanceOf(SQLException.class)
+ .hasMessageContaining("no such table");
+
+ try (ResultSet resultSet = statement.executeQuery("SELECT 40 + 2")) {
+ assertThat(resultSet.next()).isTrue();
+ assertThat(resultSet.getInt(1)).isEqualTo(42);
+ }
+ }
+ }
+}