From 09e147e29527dcc06d3db6e7573f833aea1cc419 Mon Sep 17 00:00:00 2001 From: Magnus Persson <1431685+magnusp@users.noreply.github.com> Date: Sat, 31 Oct 2026 10:32:09 +0100 Subject: [PATCH] refactor(jdbc): extract parameter stream reads (#278) --- .../libsql/jdbc/LibsqlPreparedStatement.java | 125 +++--------------- .../magnusp/libsql/jdbc/ParameterStreams.java | 115 ++++++++++++++++ .../jdbc/LibsqlPreparedStatementTest.java | 108 +++++++++++++++ 3 files changed, 239 insertions(+), 109 deletions(-) create mode 100644 libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/ParameterStreams.java diff --git a/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/LibsqlPreparedStatement.java b/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/LibsqlPreparedStatement.java index 3f9c5cf..4508f40 100644 --- a/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/LibsqlPreparedStatement.java +++ b/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/LibsqlPreparedStatement.java @@ -4,13 +4,10 @@ import io.github.magnusp.libsql.hrana.codec.StmtResult; import io.github.magnusp.libsql.hrana.codec.Value; -import java.io.ByteArrayOutputStream; -import java.io.IOException; import java.io.InputStream; import java.io.Reader; import java.math.BigDecimal; import java.net.URL; -import java.nio.charset.StandardCharsets; import java.sql.Array; import java.sql.Blob; import java.sql.Clob; @@ -296,24 +293,24 @@ public void setObject(int parameterIndex, Object x, int targetSqlType, int scale @Override public void setAsciiStream(int parameterIndex, InputStream x, int length) throws SQLException { - setParam(parameterIndex, asciiText(readBytes(x, length))); + setParam(parameterIndex, ParameterStreams.asciiText(ParameterStreams.readBytes(x, length))); } @Override @Deprecated @SuppressWarnings("deprecation") public void setUnicodeStream(int parameterIndex, InputStream x, int length) throws SQLException { - setParam(parameterIndex, utf8Text(readBytes(x, length))); + setParam(parameterIndex, ParameterStreams.utf8Text(ParameterStreams.readBytes(x, length))); } @Override public void setBinaryStream(int parameterIndex, InputStream x, int length) throws SQLException { - setParam(parameterIndex, Value.of(readBytes(x, length))); + setParam(parameterIndex, Value.of(ParameterStreams.readBytes(x, length))); } @Override public void setCharacterStream(int parameterIndex, Reader reader, int length) throws SQLException { - setParam(parameterIndex, Value.of(readCharacters(reader, length))); + setParam(parameterIndex, Value.of(ParameterStreams.readCharacters(reader, length))); } @Override @@ -323,12 +320,12 @@ public void setRef(int parameterIndex, Ref x) throws SQLException { @Override public void setBlob(int parameterIndex, Blob x) throws SQLException { - setParam(parameterIndex, Value.of(x == null ? null : readAllBytes(x.getBinaryStream()))); + setParam(parameterIndex, Value.of(x == null ? null : ParameterStreams.readAllBytes(x.getBinaryStream()))); } @Override public void setClob(int parameterIndex, Clob x) throws SQLException { - setParam(parameterIndex, Value.of(x == null ? null : readAllCharacters(x.getCharacterStream()))); + setParam(parameterIndex, Value.of(x == null ? null : ParameterStreams.readAllCharacters(x.getCharacterStream()))); } @Override @@ -441,12 +438,12 @@ public void setNClob(int parameterIndex, NClob value) throws SQLException { @Override public void setClob(int parameterIndex, Reader reader, long length) throws SQLException { - setParam(parameterIndex, Value.of(readCharacters(reader, length))); + setParam(parameterIndex, Value.of(ParameterStreams.readCharacters(reader, length))); } @Override public void setBlob(int parameterIndex, InputStream inputStream, long length) throws SQLException { - setParam(parameterIndex, Value.of(readBytes(inputStream, length))); + setParam(parameterIndex, Value.of(ParameterStreams.readBytes(inputStream, length))); } @Override @@ -461,32 +458,32 @@ public void setSQLXML(int parameterIndex, SQLXML xmlObject) throws SQLException @Override public void setAsciiStream(int parameterIndex, InputStream x, long length) throws SQLException { - setParam(parameterIndex, asciiText(readBytes(x, length))); + setParam(parameterIndex, ParameterStreams.asciiText(ParameterStreams.readBytes(x, length))); } @Override public void setBinaryStream(int parameterIndex, InputStream x, long length) throws SQLException { - setParam(parameterIndex, Value.of(readBytes(x, length))); + setParam(parameterIndex, Value.of(ParameterStreams.readBytes(x, length))); } @Override public void setCharacterStream(int parameterIndex, Reader reader, long length) throws SQLException { - setParam(parameterIndex, Value.of(readCharacters(reader, length))); + setParam(parameterIndex, Value.of(ParameterStreams.readCharacters(reader, length))); } @Override public void setAsciiStream(int parameterIndex, InputStream x) throws SQLException { - setParam(parameterIndex, asciiText(readAllBytes(x))); + setParam(parameterIndex, ParameterStreams.asciiText(ParameterStreams.readAllBytes(x))); } @Override public void setBinaryStream(int parameterIndex, InputStream x) throws SQLException { - setParam(parameterIndex, Value.of(readAllBytes(x))); + setParam(parameterIndex, Value.of(ParameterStreams.readAllBytes(x))); } @Override public void setCharacterStream(int parameterIndex, Reader reader) throws SQLException { - setParam(parameterIndex, Value.of(readAllCharacters(reader))); + setParam(parameterIndex, Value.of(ParameterStreams.readAllCharacters(reader))); } @Override @@ -496,12 +493,12 @@ public void setNCharacterStream(int parameterIndex, Reader value) throws SQLExce @Override public void setClob(int parameterIndex, Reader reader) throws SQLException { - setParam(parameterIndex, Value.of(readAllCharacters(reader))); + setParam(parameterIndex, Value.of(ParameterStreams.readAllCharacters(reader))); } @Override public void setBlob(int parameterIndex, InputStream inputStream) throws SQLException { - setParam(parameterIndex, Value.of(readAllBytes(inputStream))); + setParam(parameterIndex, Value.of(ParameterStreams.readAllBytes(inputStream))); } @Override @@ -509,94 +506,4 @@ public void setNClob(int parameterIndex, Reader reader) throws SQLException { setClob(parameterIndex, reader); } - private static Value asciiText(byte[] bytes) { - return Value.of(bytes == null ? null : new String(bytes, StandardCharsets.US_ASCII)); - } - - private static Value utf8Text(byte[] bytes) { - return Value.of(bytes == null ? null : new String(bytes, StandardCharsets.UTF_8)); - } - - private static byte[] readAllBytes(InputStream in) throws SQLException { - if (in == null) { - return null; - } - try { - ByteArrayOutputStream out = new ByteArrayOutputStream(); - byte[] buffer = new byte[8192]; - int read; - while ((read = in.read(buffer)) != -1) { - out.write(buffer, 0, read); - } - return out.toByteArray(); - } catch (IOException e) { - throw new SQLException("Cannot read from binary stream: " + e.getMessage(), e); - } - } - - private static byte[] readBytes(InputStream in, long length) throws SQLException { - if (in == null) { - return null; - } - if (length < 0 || length > Integer.MAX_VALUE) { - throw new SQLException("Data must have a length between 0 and Integer.MAX_VALUE"); - } - byte[] bytes = new byte[(int) length]; - int total = 0; - try { - while (total < bytes.length) { - int read = in.read(bytes, total, bytes.length - total); - if (read < 0) { - throw new SQLException("End of stream reached before " + length + " bytes were read"); - } - total += read; - } - } catch (IOException e) { - throw new SQLException("Cannot read from binary stream: " + e.getMessage(), e); - } - return bytes; - } - - private static String readAllCharacters(Reader reader) throws SQLException { - if (reader == null) { - return null; - } - try { - StringBuilder text = new StringBuilder(); - char[] buffer = new char[8192]; - int read; - while ((read = reader.read(buffer)) != -1) { - text.append(buffer, 0, read); - } - return text.toString(); - } catch (IOException e) { - throw new SQLException("Cannot read from character stream: " + e.getMessage(), e); - } - } - - private static String readCharacters(Reader reader, long length) throws SQLException { - if (reader == null) { - return null; - } - if (length < 0 || length > Integer.MAX_VALUE) { - throw new SQLException("Data must have a length between 0 and Integer.MAX_VALUE"); - } - StringBuilder text = new StringBuilder((int) Math.min(length, 8192)); - char[] buffer = new char[8192]; - long remaining = length; - try { - while (remaining > 0) { - int read = reader.read(buffer, 0, (int) Math.min(remaining, buffer.length)); - if (read < 0) { - throw new SQLException( - "End of stream reached before " + length + " characters were read"); - } - text.append(buffer, 0, read); - remaining -= read; - } - } catch (IOException e) { - throw new SQLException("Cannot read from character stream: " + e.getMessage(), e); - } - return text.toString(); - } } diff --git a/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/ParameterStreams.java b/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/ParameterStreams.java new file mode 100644 index 0000000..c0492e4 --- /dev/null +++ b/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/ParameterStreams.java @@ -0,0 +1,115 @@ +package io.github.magnusp.libsql.jdbc; + +import io.github.magnusp.libsql.hrana.codec.Value; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.Reader; +import java.nio.charset.StandardCharsets; +import java.sql.SQLException; + +/** + * Reads a parameter stream or reader into the Hrana value its setter binds. + * + *
Every method is a pure function of the stream, the optional length, and the character set.
+ * A null stream binds SQL NULL. A length outside the Java array range and a stream that ends early
+ * keep the sqlite-jdbc 3.53.4.0 messages, and a read failure is reported as an {@link SQLException}
+ * rather than swallowed.
+ */
+final class ParameterStreams {
+
+ private ParameterStreams() {}
+
+ static Value asciiText(byte[] bytes) {
+ return Value.of(bytes == null ? null : new String(bytes, StandardCharsets.US_ASCII));
+ }
+
+ static Value utf8Text(byte[] bytes) {
+ return Value.of(bytes == null ? null : new String(bytes, StandardCharsets.UTF_8));
+ }
+
+ static byte[] readAllBytes(InputStream in) throws SQLException {
+ if (in == null) {
+ return null;
+ }
+ try {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ byte[] buffer = new byte[8192];
+ int read;
+ while ((read = in.read(buffer)) != -1) {
+ out.write(buffer, 0, read);
+ }
+ return out.toByteArray();
+ } catch (IOException e) {
+ throw new SQLException("Cannot read from binary stream: " + e.getMessage(), e);
+ }
+ }
+
+ static byte[] readBytes(InputStream in, long length) throws SQLException {
+ if (in == null) {
+ return null;
+ }
+ if (length < 0 || length > Integer.MAX_VALUE) {
+ throw new SQLException("Data must have a length between 0 and Integer.MAX_VALUE");
+ }
+ byte[] bytes = new byte[(int) length];
+ int total = 0;
+ try {
+ while (total < bytes.length) {
+ int read = in.read(bytes, total, bytes.length - total);
+ if (read < 0) {
+ throw new SQLException(
+ "End of stream reached before " + length + " bytes were read");
+ }
+ total += read;
+ }
+ } catch (IOException e) {
+ throw new SQLException("Cannot read from binary stream: " + e.getMessage(), e);
+ }
+ return bytes;
+ }
+
+ static String readAllCharacters(Reader reader) throws SQLException {
+ if (reader == null) {
+ return null;
+ }
+ try {
+ StringBuilder text = new StringBuilder();
+ char[] buffer = new char[8192];
+ int read;
+ while ((read = reader.read(buffer)) != -1) {
+ text.append(buffer, 0, read);
+ }
+ return text.toString();
+ } catch (IOException e) {
+ throw new SQLException("Cannot read from character stream: " + e.getMessage(), e);
+ }
+ }
+
+ static String readCharacters(Reader reader, long length) throws SQLException {
+ if (reader == null) {
+ return null;
+ }
+ if (length < 0 || length > Integer.MAX_VALUE) {
+ throw new SQLException("Data must have a length between 0 and Integer.MAX_VALUE");
+ }
+ StringBuilder text = new StringBuilder((int) Math.min(length, 8192));
+ char[] buffer = new char[8192];
+ long remaining = length;
+ try {
+ while (remaining > 0) {
+ int read = reader.read(buffer, 0, (int) Math.min(remaining, buffer.length));
+ if (read < 0) {
+ throw new SQLException(
+ "End of stream reached before " + length + " characters were read");
+ }
+ text.append(buffer, 0, read);
+ remaining -= read;
+ }
+ } catch (IOException e) {
+ throw new SQLException("Cannot read from character stream: " + e.getMessage(), e);
+ }
+ return text.toString();
+ }
+}
diff --git a/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/LibsqlPreparedStatementTest.java b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/LibsqlPreparedStatementTest.java
index cf0b747..43c4c16 100644
--- a/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/LibsqlPreparedStatementTest.java
+++ b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/LibsqlPreparedStatementTest.java
@@ -8,6 +8,9 @@
import org.junit.jupiter.api.Test;
import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.Reader;
import java.io.StringReader;
import java.math.BigDecimal;
import java.math.BigInteger;
@@ -698,6 +701,111 @@ private static NClob nclob(String text) {
: null);
}
+
+ @Test
+ void nullStreamsBindSqlNull() throws SQLException {
+ RecordingConnection connection = new RecordingConnection(NO_ROWS);
+ try (LibsqlPreparedStatement statement =
+ new LibsqlPreparedStatement(connection, "insert into t values (?, ?, ?)")) {
+ statement.setBinaryStream(1, null);
+ statement.setCharacterStream(2, null);
+ statement.setAsciiStream(3, null);
+
+ statement.executeUpdate();
+ }
+
+ assertThat(connection.executed.get(0).args())
+ .containsExactly(Value.ofNull(), Value.ofNull(), Value.ofNull());
+ }
+
+ @Test
+ void streamLengthOutsideIntRangeUsesTheXerialMessage() throws SQLException {
+ try (LibsqlPreparedStatement statement =
+ new LibsqlPreparedStatement(new RecordingConnection(NO_ROWS), "insert into t values (?)")) {
+ assertThatThrownBy(() -> statement.setBinaryStream(
+ 1, new ByteArrayInputStream(new byte[0]), -1))
+ .isInstanceOf(SQLException.class)
+ .hasMessage("Data must have a length between 0 and Integer.MAX_VALUE");
+ assertThatThrownBy(() -> statement.setCharacterStream(
+ 1, new StringReader(""), Integer.MAX_VALUE + 1L))
+ .isInstanceOf(SQLException.class)
+ .hasMessage("Data must have a length between 0 and Integer.MAX_VALUE");
+ }
+ }
+
+ @Test
+ void shortBinaryStreamUsesTheXerialMessage() throws SQLException {
+ try (LibsqlPreparedStatement statement =
+ new LibsqlPreparedStatement(new RecordingConnection(NO_ROWS), "insert into t values (?)")) {
+ assertThatThrownBy(() -> statement.setBinaryStream(
+ 1, new ByteArrayInputStream(new byte[] {1, 2, 3}), 5))
+ .isInstanceOf(SQLException.class)
+ .hasMessage("End of stream reached before 5 bytes were read");
+ }
+ }
+
+ @Test
+ void shortCharacterStreamUsesTheXerialMessage() throws SQLException {
+ try (LibsqlPreparedStatement statement =
+ new LibsqlPreparedStatement(new RecordingConnection(NO_ROWS), "insert into t values (?)")) {
+ assertThatThrownBy(() -> statement.setCharacterStream(1, new StringReader("abc"), 5))
+ .isInstanceOf(SQLException.class)
+ .hasMessage("End of stream reached before 5 characters were read");
+ }
+ }
+
+ @Test
+ void binaryReadFailureKeepsTheXerialMessage() throws SQLException {
+ InputStream failing = new InputStream() {
+ @Override
+ public int read() throws IOException {
+ throw new IOException("boom");
+ }
+ };
+ try (LibsqlPreparedStatement statement =
+ new LibsqlPreparedStatement(new RecordingConnection(NO_ROWS), "insert into t values (?)")) {
+ assertThatThrownBy(() -> statement.setBinaryStream(1, failing))
+ .isInstanceOf(SQLException.class)
+ .hasMessage("Cannot read from binary stream: boom");
+ }
+ }
+
+ @Test
+ void characterReadFailureKeepsTheXerialMessage() throws SQLException {
+ Reader failing = new Reader() {
+ @Override
+ public int read(char[] buffer, int offset, int length) throws IOException {
+ throw new IOException("boom");
+ }
+
+ @Override
+ public void close() {}
+ };
+ try (LibsqlPreparedStatement statement =
+ new LibsqlPreparedStatement(new RecordingConnection(NO_ROWS), "insert into t values (?)")) {
+ assertThatThrownBy(() -> statement.setCharacterStream(1, failing))
+ .isInstanceOf(SQLException.class)
+ .hasMessage("Cannot read from character stream: boom");
+ }
+ }
+
+ @SuppressWarnings("deprecation")
+ @Test
+ void asciiStreamDecodesAsAsciiAndUnicodeStreamDecodesAsUtf8() throws SQLException {
+ RecordingConnection connection = new RecordingConnection(NO_ROWS);
+ byte[] eAcute = "\u00e9".getBytes(StandardCharsets.UTF_8);
+ try (LibsqlPreparedStatement statement =
+ new LibsqlPreparedStatement(connection, "insert into t values (?, ?)")) {
+ statement.setAsciiStream(1, new ByteArrayInputStream(eAcute), eAcute.length);
+ statement.setUnicodeStream(2, new ByteArrayInputStream(eAcute), eAcute.length);
+
+ statement.executeUpdate();
+ }
+
+ assertThat(connection.executed.get(0).args())
+ .containsExactly(Value.of("\uFFFD\uFFFD"), Value.of("\u00e9"));
+ }
+
private static final class RecordingConnection extends LibsqlConnection {
private final StmtResult result;
private final List