From f098fc4f8c49ef47ff5b644b3092f714bb6872ac Mon Sep 17 00:00:00 2001 From: Magnus Persson <1431685+magnusp@users.noreply.github.com> Date: Fri, 23 Oct 2026 05:24:04 +0200 Subject: [PATCH] fix(jdbc): close the prepared-statement binding and metadata gaps Derive the parameter count from the SQL placeholders and answer ParameterMetaData with Types.OTHER where Hrana has no describe. Bind SQL NULL for unset and cleared parameters on both statement paths. Convert the blob, clob, binary, ascii, unicode, and character stream setters locally. Encode surrogate pairs as one UTF-8 sequence in the direct Hrana JSON writer. --- .../hrana/codec/DirectHranaJsonCodec.java | 11 +- .../hrana/codec/DirectHranaJsonCodecTest.java | 14 + .../libsql/jdbc/LibsqlPreparedStatement.java | 374 ++++++++++++++++-- .../magnusp/libsql/jdbc/LibsqlStatement.java | 24 +- .../magnusp/libsql/jdbc/SqlTokenizer.java | 14 + .../jdbc/LibsqlPreparedStatementTest.java | 112 ++++++ .../libsql/jdbc/LibsqlStatementTest.java | 12 + 7 files changed, 523 insertions(+), 38 deletions(-) diff --git a/libsql-hrana-codec/src/main/java/com/github/magnusp/libsql/hrana/codec/DirectHranaJsonCodec.java b/libsql-hrana-codec/src/main/java/com/github/magnusp/libsql/hrana/codec/DirectHranaJsonCodec.java index d0c0654..d46042b 100644 --- a/libsql-hrana-codec/src/main/java/com/github/magnusp/libsql/hrana/codec/DirectHranaJsonCodec.java +++ b/libsql-hrana-codec/src/main/java/com/github/magnusp/libsql/hrana/codec/DirectHranaJsonCodec.java @@ -694,7 +694,16 @@ public void writeString(String s) { } else if (c < 0x80) { writeByte((byte) c); } else { - byte[] utf8 = String.valueOf(c).getBytes(StandardCharsets.UTF_8); + String chunk; + if (Character.isHighSurrogate(c) + && i + 1 < len + && Character.isLowSurrogate(s.charAt(i + 1))) { + chunk = s.substring(i, i + 2); + i++; + } else { + chunk = String.valueOf(c); + } + byte[] utf8 = chunk.getBytes(StandardCharsets.UTF_8); ensureCapacity(count + utf8.length); System.arraycopy(utf8, 0, buf, count, utf8.length); count += utf8.length; diff --git a/libsql-hrana-codec/src/test/java/com/github/magnusp/libsql/hrana/codec/DirectHranaJsonCodecTest.java b/libsql-hrana-codec/src/test/java/com/github/magnusp/libsql/hrana/codec/DirectHranaJsonCodecTest.java index 588043b..c393009 100644 --- a/libsql-hrana-codec/src/test/java/com/github/magnusp/libsql/hrana/codec/DirectHranaJsonCodecTest.java +++ b/libsql-hrana-codec/src/test/java/com/github/magnusp/libsql/hrana/codec/DirectHranaJsonCodecTest.java @@ -34,6 +34,20 @@ void testSerializePipelineRequestOneShot() throws IOException { assertThat(json).contains("\"value\":\"hello \\\"world\\\"\\nnewline\""); } + @Test + void testSerializeSupplementaryCharactersAsUtf8() throws IOException { + String supplementary = "\uD840\uDC40 and \uD880\uDC99"; + Stmt stmt = new Stmt("SELECT '" + supplementary + "'", List.of(Value.of(supplementary))); + PipelineReqBody req = new PipelineReqBody(null, List.of(new StreamRequest.Execute(stmt))); + + byte[] jsonBytes = codec.serializePipelineRequest(req); + String json = new String(jsonBytes, StandardCharsets.UTF_8); + + assertThat(json).contains("\"sql\":\"SELECT '" + supplementary + "'\""); + assertThat(json).contains("\"value\":\"" + supplementary + "\""); + assertThat(json).doesNotContain("?"); + } + @Test void testDeserializePipelineResponse() throws IOException { String json = """ diff --git a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlPreparedStatement.java b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlPreparedStatement.java index 2dcb12d..9ce6b7b 100644 --- a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlPreparedStatement.java +++ b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlPreparedStatement.java @@ -4,10 +4,13 @@ import com.github.magnusp.libsql.hrana.codec.StmtResult; import com.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; @@ -20,9 +23,11 @@ import java.sql.ResultSetMetaData; import java.sql.RowId; import java.sql.SQLException; +import java.sql.SQLFeatureNotSupportedException; import java.sql.SQLXML; import java.sql.Time; import java.sql.Timestamp; +import java.sql.Types; import java.util.ArrayList; import java.util.Calendar; import java.util.List; @@ -35,11 +40,17 @@ * #getGeneratedKeys()} throw after {@link #close()}. A prepared execute already throws once closed, * so there is no post-close execute that could expose a result set. sqlite-jdbc 3.53.4.0 instead * returns generated keys, and rejects {@code addBatch()} with a different message; see issue #87. + * + *

Parameter metadata is derived locally. The placeholder count comes from the SQL text, but the + * parameter type, precision, and scale stay unknown because Hrana has no describe request. The + * stream and {@link Blob}/{@link Clob} setters convert to a TEXT or BLOB value locally. An unset + * parameter, including one released by {@link #clearParameters()}, binds SQL NULL. */ -public class LibsqlPreparedStatement extends LibsqlStatement implements PreparedStatement { +public class LibsqlPreparedStatement extends LibsqlStatement implements PreparedStatement, ParameterMetaData { private final String sql; private final boolean returnsResultSet; + private final int parameterCount; // Mitigation for Bug 7: Sequential ArrayList instead of Map to eliminate Integer autoboxing overhead private final List parameters = new ArrayList<>(); @@ -47,6 +58,7 @@ public LibsqlPreparedStatement(LibsqlConnection connection, String sql) { super(connection); this.sql = sql; this.returnsResultSet = returnsResultSet(sql); + this.parameterCount = SqlTokenizer.placeholderCount(sql); } private void setParam(int parameterIndex, Value value) throws SQLException { @@ -59,7 +71,12 @@ private void setParam(int parameterIndex, Value value) throws SQLException { } private Stmt buildCurrentStmt() { - return new Stmt(sql, new ArrayList<>(parameters)); + List args = new ArrayList<>(parameters); + while (args.size() < parameterCount) { + // An unset parameter, including one released by clearParameters, binds SQL NULL. + args.add(Value.ofNull()); + } + return new Stmt(sql, args); } @Override @@ -352,37 +369,324 @@ public void setObject(int parameterIndex, Object x, int targetSqlType, int scale setObject(parameterIndex, x); } - @Override public void setAsciiStream(int parameterIndex, InputStream x, int length) throws SQLException { throw new UnsupportedOperationException(); } - @Override @Deprecated @SuppressWarnings("deprecation") public void setUnicodeStream(int parameterIndex, InputStream x, int length) throws SQLException { throw new UnsupportedOperationException(); } - @Override public void setBinaryStream(int parameterIndex, InputStream x, int length) throws SQLException { throw new UnsupportedOperationException(); } - @Override public void setCharacterStream(int parameterIndex, Reader reader, int length) throws SQLException { throw new UnsupportedOperationException(); } - @Override public void setRef(int parameterIndex, Ref x) throws SQLException { throw new UnsupportedOperationException(); } - @Override public void setBlob(int parameterIndex, Blob x) throws SQLException { throw new UnsupportedOperationException(); } - @Override public void setClob(int parameterIndex, Clob x) throws SQLException { throw new UnsupportedOperationException(); } - @Override public void setArray(int parameterIndex, Array x) throws SQLException { throw new UnsupportedOperationException(); } - @Override public ResultSetMetaData getMetaData() throws SQLException { return currentResultSet != null ? currentResultSet.getMetaData() : null; } - @Override public void setDate(int parameterIndex, Date x, Calendar cal) throws SQLException { setDate(parameterIndex, x); } - @Override public void setTime(int parameterIndex, Time x, Calendar cal) throws SQLException { setTime(parameterIndex, x); } - @Override public void setTimestamp(int parameterIndex, Timestamp x, Calendar cal) throws SQLException { setTimestamp(parameterIndex, x); } - @Override public void setNull(int parameterIndex, int sqlType, String typeName) throws SQLException { setNull(parameterIndex, sqlType); } - @Override public void setURL(int parameterIndex, URL x) throws SQLException { setString(parameterIndex, x != null ? x.toString() : null); } - @Override public ParameterMetaData getParameterMetaData() throws SQLException { throw new UnsupportedOperationException(); } - @Override public void setRowId(int parameterIndex, RowId x) throws SQLException { throw new UnsupportedOperationException(); } - @Override public void setNString(int parameterIndex, String value) throws SQLException { setString(parameterIndex, value); } - @Override public void setNCharacterStream(int parameterIndex, Reader value, long length) throws SQLException { throw new UnsupportedOperationException(); } - @Override public void setNClob(int parameterIndex, NClob value) throws SQLException { throw new UnsupportedOperationException(); } - @Override public void setClob(int parameterIndex, Reader reader, long length) throws SQLException { throw new UnsupportedOperationException(); } - @Override public void setBlob(int parameterIndex, InputStream inputStream, long length) throws SQLException { throw new UnsupportedOperationException(); } - @Override public void setNClob(int parameterIndex, Reader reader, long length) throws SQLException { throw new UnsupportedOperationException(); } - @Override public void setSQLXML(int parameterIndex, SQLXML xmlObject) throws SQLException { throw new UnsupportedOperationException(); } - @Override public void setAsciiStream(int parameterIndex, InputStream x, long length) throws SQLException { throw new UnsupportedOperationException(); } - @Override public void setBinaryStream(int parameterIndex, InputStream x, long length) throws SQLException { throw new UnsupportedOperationException(); } - @Override public void setCharacterStream(int parameterIndex, Reader reader, long length) throws SQLException { throw new UnsupportedOperationException(); } - @Override public void setAsciiStream(int parameterIndex, InputStream x) throws SQLException { throw new UnsupportedOperationException(); } - @Override public void setBinaryStream(int parameterIndex, InputStream x) throws SQLException { throw new UnsupportedOperationException(); } - @Override public void setCharacterStream(int parameterIndex, Reader reader) throws SQLException { throw new UnsupportedOperationException(); } - @Override public void setNCharacterStream(int parameterIndex, Reader value) throws SQLException { throw new UnsupportedOperationException(); } - @Override public void setClob(int parameterIndex, Reader reader) throws SQLException { throw new UnsupportedOperationException(); } - @Override public void setBlob(int parameterIndex, InputStream inputStream) throws SQLException { throw new UnsupportedOperationException(); } - @Override public void setNClob(int parameterIndex, Reader reader) throws SQLException { throw new UnsupportedOperationException(); } + @Override + public void setAsciiStream(int parameterIndex, InputStream x, int length) throws SQLException { + setParam(parameterIndex, asciiText(readBytes(x, length))); + } + + @Override + @Deprecated + @SuppressWarnings("deprecation") + public void setUnicodeStream(int parameterIndex, InputStream x, int length) throws SQLException { + setParam(parameterIndex, utf8Text(readBytes(x, length))); + } + + @Override + public void setBinaryStream(int parameterIndex, InputStream x, int length) throws SQLException { + setParam(parameterIndex, Value.of(readBytes(x, length))); + } + + @Override + public void setCharacterStream(int parameterIndex, Reader reader, int length) throws SQLException { + setParam(parameterIndex, Value.of(readCharacters(reader, length))); + } + + @Override + public void setRef(int parameterIndex, Ref x) throws SQLException { + throw new SQLFeatureNotSupportedException("Ref parameters are not supported"); + } + + @Override + public void setBlob(int parameterIndex, Blob x) throws SQLException { + setParam(parameterIndex, Value.of(x == null ? null : readAllBytes(x.getBinaryStream()))); + } + + @Override + public void setClob(int parameterIndex, Clob x) throws SQLException { + setParam(parameterIndex, Value.of(x == null ? null : readAllCharacters(x.getCharacterStream()))); + } + + @Override + public void setArray(int parameterIndex, Array x) throws SQLException { + throw new SQLFeatureNotSupportedException("Array parameters are not supported"); + } + + @Override + public ResultSetMetaData getMetaData() throws SQLException { + return currentResultSet != null ? currentResultSet.getMetaData() : null; + } + + @Override + public void setDate(int parameterIndex, Date x, Calendar cal) throws SQLException { + setDate(parameterIndex, x); + } + + @Override + public void setTime(int parameterIndex, Time x, Calendar cal) throws SQLException { + setTime(parameterIndex, x); + } + + @Override + public void setTimestamp(int parameterIndex, Timestamp x, Calendar cal) throws SQLException { + setTimestamp(parameterIndex, x); + } + + @Override + public void setNull(int parameterIndex, int sqlType, String typeName) throws SQLException { + setNull(parameterIndex, sqlType); + } + + @Override + public void setURL(int parameterIndex, URL x) throws SQLException { + setString(parameterIndex, x != null ? x.toString() : null); + } + + @Override + public ParameterMetaData getParameterMetaData() throws SQLException { + checkClosed(); + return this; + } + + @Override + public int getParameterCount() throws SQLException { + checkClosed(); + return parameterCount; + } + + @Override + public int isNullable(int param) throws SQLException { + checkParameterIndex(param); + return ParameterMetaData.parameterNullableUnknown; + } + + @Override + public boolean isSigned(int param) throws SQLException { + checkParameterIndex(param); + return true; + } + + @Override + public int getPrecision(int param) throws SQLException { + checkParameterIndex(param); + return 0; + } + + @Override + public int getScale(int param) throws SQLException { + checkParameterIndex(param); + return 0; + } + + @Override + public int getParameterType(int param) throws SQLException { + checkParameterIndex(param); + // Hrana has no describe statement, so a parameter type is unknown until it is executed. + return Types.OTHER; + } + + @Override + public String getParameterTypeName(int param) throws SQLException { + checkParameterIndex(param); + return "OTHER"; + } + + @Override + public String getParameterClassName(int param) throws SQLException { + checkParameterIndex(param); + return Object.class.getName(); + } + + @Override + public int getParameterMode(int param) throws SQLException { + checkParameterIndex(param); + return ParameterMetaData.parameterModeIn; + } + + private void checkParameterIndex(int param) throws SQLException { + checkClosed(); + if (param < 1 || param > parameterCount) { + throw new SQLException("Parameter index out of range: " + param); + } + } + + @Override + public void setRowId(int parameterIndex, RowId x) throws SQLException { + throw new SQLFeatureNotSupportedException("RowId parameters are not supported"); + } + + @Override + public void setNString(int parameterIndex, String value) throws SQLException { + setString(parameterIndex, value); + } + + @Override + public void setNCharacterStream(int parameterIndex, Reader value, long length) throws SQLException { + setCharacterStream(parameterIndex, value, length); + } + + @Override + public void setNClob(int parameterIndex, NClob value) throws SQLException { + setClob(parameterIndex, (Clob) value); + } + + @Override + public void setClob(int parameterIndex, Reader reader, long length) throws SQLException { + setParam(parameterIndex, Value.of(readCharacters(reader, length))); + } + + @Override + public void setBlob(int parameterIndex, InputStream inputStream, long length) throws SQLException { + setParam(parameterIndex, Value.of(readBytes(inputStream, length))); + } + + @Override + public void setNClob(int parameterIndex, Reader reader, long length) throws SQLException { + setClob(parameterIndex, reader, length); + } + + @Override + public void setSQLXML(int parameterIndex, SQLXML xmlObject) throws SQLException { + throw new SQLFeatureNotSupportedException("SQLXML parameters are not supported"); + } + + @Override + public void setAsciiStream(int parameterIndex, InputStream x, long length) throws SQLException { + setParam(parameterIndex, asciiText(readBytes(x, length))); + } + + @Override + public void setBinaryStream(int parameterIndex, InputStream x, long length) throws SQLException { + setParam(parameterIndex, Value.of(readBytes(x, length))); + } + + @Override + public void setCharacterStream(int parameterIndex, Reader reader, long length) throws SQLException { + setParam(parameterIndex, Value.of(readCharacters(reader, length))); + } + + @Override + public void setAsciiStream(int parameterIndex, InputStream x) throws SQLException { + setParam(parameterIndex, asciiText(readAllBytes(x))); + } + + @Override + public void setBinaryStream(int parameterIndex, InputStream x) throws SQLException { + setParam(parameterIndex, Value.of(readAllBytes(x))); + } + + @Override + public void setCharacterStream(int parameterIndex, Reader reader) throws SQLException { + setParam(parameterIndex, Value.of(readAllCharacters(reader))); + } + + @Override + public void setNCharacterStream(int parameterIndex, Reader value) throws SQLException { + setCharacterStream(parameterIndex, value); + } + + @Override + public void setClob(int parameterIndex, Reader reader) throws SQLException { + setParam(parameterIndex, Value.of(readAllCharacters(reader))); + } + + @Override + public void setBlob(int parameterIndex, InputStream inputStream) throws SQLException { + setParam(parameterIndex, Value.of(readAllBytes(inputStream))); + } + + @Override + 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/com/github/magnusp/libsql/jdbc/LibsqlStatement.java b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlStatement.java index 1492170..ea60206 100644 --- a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlStatement.java +++ b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlStatement.java @@ -180,7 +180,27 @@ protected boolean executeResult(String sql, StmtResult result) throws SQLExcepti private StmtResult executeSql(String sql) throws SQLException { checkEscapeProcessing(sql); beginExecution(); - return connection.executeStatement(new Stmt(sql)); + return connection.executeStatement(statement(sql)); + } + + /** + * Builds a statement with SQL NULL for every placeholder a plain {@code Statement} leaves + * unbound, matching SQLite, where an unset parameter reads as NULL. Hrana rejects an argument + * list shorter than the SQL placeholders, so the nulls must be sent explicitly. + */ + static Stmt statement(String sql) { + if (sql == null || sql.indexOf('?') < 0) { + return new Stmt(sql); + } + int placeholders = SqlTokenizer.placeholderCount(sql); + if (placeholders == 0) { + return new Stmt(sql); + } + List args = new ArrayList<>(placeholders); + for (int i = 0; i < placeholders; i++) { + args.add(Value.ofNull()); + } + return new Stmt(sql, args); } /** @@ -521,7 +541,7 @@ public boolean getMoreResults() throws SQLException { public void addBatch(String sql) throws SQLException { checkClosed(); checkEscapeProcessing(sql); - batchStatements.add(new Stmt(sql)); + batchStatements.add(statement(sql)); } @Override diff --git a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/SqlTokenizer.java b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/SqlTokenizer.java index 0792c78..6c5bb0b 100644 --- a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/SqlTokenizer.java +++ b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/SqlTokenizer.java @@ -147,6 +147,20 @@ static List tokenize(String sql) { return tokens; } + static int placeholderCount(String sql) { + List tokens = tokenize(sql); + if (tokens == null) { + return 0; + } + int count = 0; + for (Token token : tokens) { + if (token.isSymbol("?")) { + count++; + } + } + return count; + } + static boolean isWordAt(List tokens, int index, int depth, String word) { if (index < 0 || index >= tokens.size()) { return false; diff --git a/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlPreparedStatementTest.java b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlPreparedStatementTest.java index ad08f45..aa2626e 100644 --- a/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlPreparedStatementTest.java +++ b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlPreparedStatementTest.java @@ -7,9 +7,14 @@ import com.github.magnusp.libsql.hrana.codec.Value; import org.junit.jupiter.api.Test; +import java.io.ByteArrayInputStream; +import java.io.StringReader; +import java.nio.charset.StandardCharsets; +import java.sql.ParameterMetaData; import java.sql.ResultSet; import java.sql.SQLException; import java.sql.SQLFeatureNotSupportedException; +import java.sql.Types; import java.util.ArrayList; import java.util.List; @@ -148,9 +153,115 @@ void escapeProcessingAppliesToPreparedSql() throws SQLException { } } + @Test + void parameterCountComesFromThePlaceholders() throws SQLException { + RecordingConnection connection = new RecordingConnection(NO_ROWS); + try (LibsqlPreparedStatement statement = + new LibsqlPreparedStatement(connection, "select ?, ?, ?")) { + assertThat(statement.getParameterMetaData().getParameterCount()).isEqualTo(3); + } + try (LibsqlPreparedStatement statement = new LibsqlPreparedStatement( + connection, "select '?', \"?\", ? -- trailing ?\n")) { + assertThat(statement.getParameterMetaData().getParameterCount()).isEqualTo(1); + } + } + + @Test + void parameterTypesAreUnknownWithoutADescription() throws SQLException { + RecordingConnection connection = new RecordingConnection(NO_ROWS); + try (LibsqlPreparedStatement statement = + new LibsqlPreparedStatement(connection, "insert into t values (?)")) { + ParameterMetaData meta = statement.getParameterMetaData(); + + assertThat(meta.getParameterType(1)).isEqualTo(Types.OTHER); + assertThat(meta.getParameterTypeName(1)).isEqualTo("OTHER"); + assertThat(meta.getParameterMode(1)).isEqualTo(ParameterMetaData.parameterModeIn); + } + } + + @Test + void clearParametersBindsNullArguments() throws SQLException { + RecordingConnection connection = new RecordingConnection(NO_ROWS); + try (LibsqlPreparedStatement statement = + new LibsqlPreparedStatement(connection, "insert into t values (?)")) { + statement.setString(1, "value"); + statement.clearParameters(); + + assertThat(statement.executeUpdate()).isEqualTo(1); + } + + assertThat(connection.executed.get(0).args()).containsExactly(Value.ofNull()); + } + + @Test + void unsetParametersBindNullArguments() throws SQLException { + RecordingConnection connection = new RecordingConnection(NO_ROWS); + try (LibsqlPreparedStatement statement = + new LibsqlPreparedStatement(connection, "insert into t values (?, ?)")) { + statement.setInt(1, 7); + + statement.executeUpdate(); + } + + assertThat(connection.executed.get(0).args()) + .containsExactly(Value.of(7L), Value.ofNull()); + } + + @Test + void streamSettersBindLocalConversions() throws SQLException { + RecordingConnection connection = new RecordingConnection(NO_ROWS); + byte[] bytes = {1, 2, 3}; + String supplementary = "\uD840\uDC40 stream"; + try (LibsqlPreparedStatement statement = new LibsqlPreparedStatement( + connection, "insert into t values (?, ?, ?, ?, ?)")) { + statement.setClob(1, new StringReader("clob")); + statement.setBlob(2, new ByteArrayInputStream(bytes)); + statement.setBinaryStream(3, new ByteArrayInputStream(bytes), bytes.length); + statement.setAsciiStream( + 4, + new ByteArrayInputStream("ascii".getBytes(StandardCharsets.US_ASCII)), + "ascii".length()); + statement.setUnicodeStream( + 5, + new ByteArrayInputStream(supplementary.getBytes(StandardCharsets.UTF_8)), + supplementary.getBytes(StandardCharsets.UTF_8).length); + + statement.executeUpdate(); + } + + assertThat(connection.executed.get(0).args()) + .containsExactly( + Value.of("clob"), + Value.of(bytes), + Value.of(bytes), + Value.of("ascii"), + Value.of(supplementary)); + } + + @Test + void streamSettersWithoutALengthReadToEndOfStream() throws SQLException { + RecordingConnection connection = new RecordingConnection(NO_ROWS); + byte[] bytes = {4, 5, 6}; + try (LibsqlPreparedStatement statement = new LibsqlPreparedStatement( + connection, "insert into t values (?, ?, ?, ?)")) { + statement.setClob(1, new StringReader("clob")); + statement.setBlob(2, new ByteArrayInputStream(bytes)); + statement.setBinaryStream(3, new ByteArrayInputStream(bytes)); + statement.setAsciiStream( + 4, new ByteArrayInputStream("ascii".getBytes(StandardCharsets.US_ASCII))); + + statement.executeUpdate(); + } + + assertThat(connection.executed.get(0).args()) + .containsExactly(Value.of("clob"), Value.of(bytes), Value.of(bytes), + Value.of("ascii")); + } + private static final class RecordingConnection extends LibsqlConnection { private final StmtResult result; private final List executedSql = new ArrayList<>(); + private final List executed = new ArrayList<>(); private int executeCalls; private RecordingConnection(StmtResult result) { @@ -162,6 +273,7 @@ private RecordingConnection(StmtResult result) { public StmtResult executeStatement(Stmt stmt) { executeCalls++; executedSql.add(stmt.sql()); + executed.add(stmt); return result; } } diff --git a/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlStatementTest.java b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlStatementTest.java index 64306a3..e430881 100644 --- a/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlStatementTest.java +++ b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlStatementTest.java @@ -181,10 +181,21 @@ void batchThatReturnsResultsThrowsBatchUpdateException() throws SQLException { .containsExactly(1, 0)); } + @Test + void plainStatementBindsNullForUnboundPlaceholders() throws SQLException { + RecordingConnection connection = new RecordingConnection(THREE_ROWS); + LibsqlStatement statement = new LibsqlStatement(connection); + + statement.execute("insert into t values (?)"); + + assertThat(connection.executedStatements.get(0).args()).containsExactly(Value.ofNull()); + } + private static final class RecordingConnection extends LibsqlConnection { private final StmtResult result; private final List batchResults; private final List executedSql = new ArrayList<>(); + private final List executedStatements = new ArrayList<>(); private RecordingConnection(StmtResult result) { this(result, List.of()); @@ -199,6 +210,7 @@ private RecordingConnection(StmtResult result, List batchResults) { @Override public StmtResult executeStatement(Stmt stmt) { executedSql.add(stmt.sql()); + executedStatements.add(stmt); return result; }