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 executedSql = new ArrayList<>();