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; }