From ace98f70ed67f067e6731f8f710ccd47e0a36af3 Mon Sep 17 00:00:00 2001 From: Magnus Persson <1431685+magnusp@users.noreply.github.com> Date: Sun, 25 Oct 2026 03:09:49 +0100 Subject: [PATCH 1/3] feat(jdbc): add the JDBC value converter The converter turns a Java value into the Hrana value for a target JDBC type per Appendix B Table B-5, and follows Table B-4 when no target is given. Unlisted pairs throw SQLFeatureNotSupportedException so the target is never ignored. --- .../magnusp/libsql/jdbc/JdbcTypeMapping.java | 43 ++ .../libsql/jdbc/JdbcValueConverter.java | 528 ++++++++++++++++++ 2 files changed, 571 insertions(+) create mode 100644 libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/JdbcValueConverter.java diff --git a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/JdbcTypeMapping.java b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/JdbcTypeMapping.java index b2883f8..83ddc7f 100644 --- a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/JdbcTypeMapping.java +++ b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/JdbcTypeMapping.java @@ -200,4 +200,47 @@ private static void alias(Map aliases, String spelling, String } aliases.put(spelling, type); } + + /** Names a JDBC type for an error message or the compliance profile. */ + static String sqlTypeName(int sqlType) { + return switch (sqlType) { + case Types.TINYINT -> "TINYINT"; + case Types.SMALLINT -> "SMALLINT"; + case Types.INTEGER -> "INTEGER"; + case Types.BIGINT -> "BIGINT"; + case Types.REAL -> "REAL"; + case Types.FLOAT -> "FLOAT"; + case Types.DOUBLE -> "DOUBLE"; + case Types.NUMERIC -> "NUMERIC"; + case Types.DECIMAL -> "DECIMAL"; + case Types.BIT -> "BIT"; + case Types.BOOLEAN -> "BOOLEAN"; + case Types.CHAR -> "CHAR"; + case Types.VARCHAR -> "VARCHAR"; + case Types.LONGVARCHAR -> "LONGVARCHAR"; + case Types.NCHAR -> "NCHAR"; + case Types.NVARCHAR -> "NVARCHAR"; + case Types.LONGNVARCHAR -> "LONGNVARCHAR"; + case Types.BINARY -> "BINARY"; + case Types.VARBINARY -> "VARBINARY"; + case Types.LONGVARBINARY -> "LONGVARBINARY"; + case Types.DATE -> "DATE"; + case Types.TIME -> "TIME"; + case Types.TIMESTAMP -> "TIMESTAMP"; + case Types.CLOB -> "CLOB"; + case Types.NCLOB -> "NCLOB"; + case Types.BLOB -> "BLOB"; + case Types.DATALINK -> "DATALINK"; + case Types.ARRAY -> "ARRAY"; + case Types.STRUCT -> "STRUCT"; + case Types.REF -> "REF"; + case Types.JAVA_OBJECT -> "JAVA_OBJECT"; + case Types.ROWID -> "ROWID"; + case Types.SQLXML -> "SQLXML"; + case Types.DISTINCT -> "DISTINCT"; + case Types.TIME_WITH_TIMEZONE -> "TIME_WITH_TIMEZONE"; + case Types.TIMESTAMP_WITH_TIMEZONE -> "TIMESTAMP_WITH_TIMEZONE"; + default -> "SQL type " + sqlType; + }; + } } diff --git a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/JdbcValueConverter.java b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/JdbcValueConverter.java new file mode 100644 index 0000000..d639305 --- /dev/null +++ b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/JdbcValueConverter.java @@ -0,0 +1,528 @@ +package com.github.magnusp.libsql.jdbc; + +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.math.BigInteger; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.sql.Array; +import java.sql.Blob; +import java.sql.Clob; +import java.sql.Date; +import java.sql.NClob; +import java.sql.Ref; +import java.sql.RowId; +import java.sql.SQLException; +import java.sql.SQLFeatureNotSupportedException; +import java.sql.SQLXML; +import java.sql.Struct; +import java.sql.Time; +import java.sql.Timestamp; +import java.sql.Types; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.OffsetDateTime; +import java.time.OffsetTime; +import java.time.ZoneId; +import java.util.Calendar; + +/** + * Converts a Java value to the Hrana value bound for a parameter. + * + *

{@link #toValue(Object, int)} implements JDBC 4.3 Appendix B, Table B-5: the value is first + * converted to the target JDBC type and then to the Hrana value of that type's storage class. A + * pair that Table B-5 does not list throws {@link SQLFeatureNotSupportedException}, so the target + * type is never silently ignored. {@link #toValue(Object)} implements Table B-4 for the + * {@code setObject} call that has no target type. + * + *

The converter is a pure function of the value and the target type. It holds no statement or + * result-set state. LOB content is read through {@link Clob#getCharacterStream()} and {@link + * Blob#getBinaryStream()}; a LOB is never converted with {@code toString}. + * + *

A text target uses the Java {@code toString} form, with one documented exception: a {@link + * Boolean} binds {@code "true"} or {@code "false"} rather than {@code "TRUE"} or {@code "FALSE"}, + * so the text round trips through {@code getBoolean}. Numeric targets narrow with Java primitive + * conversion rules. Integral and boolean targets bind Hrana integers, and decimal targets bind + * decimal text. Date and time targets bind the SQLite text format that {@code getDate}, {@code + * getTime}, and {@code getTimestamp} parse. + */ +final class JdbcValueConverter { + + private JdbcValueConverter() {} + + /** Converts a value with no target type, per Table B-4. */ + static Value toValue(Object value) throws SQLException { + if (value == null) { + return Value.ofNull(); + } + if (value instanceof String text) { + return Value.of(text); + } + if (value instanceof BigDecimal decimal) { + return Value.of(decimal.toString()); + } + if (value instanceof BigInteger integer) { + return Value.of(bigIntegerLong(integer)); + } + if (value instanceof Boolean bool) { + return Value.of(bool ? 1L : 0L); + } + if (value instanceof Byte || value instanceof Short + || value instanceof Integer || value instanceof Long) { + return Value.of(((Number) value).longValue()); + } + if (value instanceof Float floating) { + return Value.of((double) floating); + } + if (value instanceof Double floating) { + return Value.of((double) floating); + } + if (value instanceof byte[] bytes) { + return Value.of(bytes); + } + if (value instanceof Timestamp timestamp) { + return Value.of(formatTimestamp(timestamp.toLocalDateTime())); + } + if (value instanceof Time time) { + return Value.of(formatTime(time.toLocalTime())); + } + if (value instanceof Date date) { + return Value.of(date.toString()); + } + if (value instanceof LocalDate date) { + return Value.of(date.toString()); + } + if (value instanceof LocalTime time) { + return Value.of(formatTime(time)); + } + if (value instanceof LocalDateTime dateTime) { + return Value.of(formatTimestamp(dateTime)); + } + if (value instanceof OffsetTime time) { + return Value.of(time.toString()); + } + if (value instanceof OffsetDateTime dateTime) { + return Value.of(dateTime.toString()); + } + if (value instanceof Calendar calendar) { + return Value.of(formatTimestamp(localDateTime(calendar.toInstant()))); + } + if (value instanceof java.util.Date date) { + return Value.of(formatTimestamp(localDateTime(date.toInstant()))); + } + if (value instanceof Clob clob) { + return Value.of(characterText(clob)); + } + if (value instanceof Blob blob) { + return Value.of(binaryBytes(blob)); + } + // Table B-4 maps every other Java class to JAVA_OBJECT. PR-5 decides whether those types + // are supported; until then they keep the sqlite-jdbc text binding. + return Value.of(value.toString()); + } + + /** Converts a value to the target JDBC type, then to the Hrana value of that type. */ + static Value toValue(Object value, int targetSqlType) throws SQLException { + if (value == null) { + return Value.ofNull(); + } + return switch (targetSqlType) { + case Types.TINYINT -> Value.of((long) numeric(value, targetSqlType).byteValue()); + case Types.SMALLINT -> Value.of((long) numeric(value, targetSqlType).shortValue()); + case Types.INTEGER -> Value.of((long) numeric(value, targetSqlType).intValue()); + case Types.BIGINT -> Value.of(bigInt(value)); + case Types.REAL -> Value.of((double) numeric(value, targetSqlType).floatValue()); + case Types.FLOAT, Types.DOUBLE -> Value.of(numeric(value, targetSqlType).doubleValue()); + case Types.DECIMAL, Types.NUMERIC -> Value.of(numeric(value, targetSqlType).toString()); + case Types.BIT, Types.BOOLEAN -> Value.of(bit(value, targetSqlType)); + case Types.CHAR, Types.VARCHAR, Types.LONGVARCHAR -> Value.of(text(value, targetSqlType)); + case Types.NCHAR, Types.NVARCHAR, Types.LONGNVARCHAR -> + Value.of(nationalText(value, targetSqlType)); + case Types.BINARY, Types.VARBINARY, Types.LONGVARBINARY -> + Value.of(binary(value, targetSqlType)); + case Types.BLOB -> Value.of(blobBytes(value)); + case Types.CLOB -> Value.of(clobText(value)); + case Types.NCLOB -> Value.of(nclobText(value)); + case Types.DATE -> Value.of(dateText(value, targetSqlType)); + case Types.TIME -> Value.of(timeText(value, targetSqlType)); + case Types.TIMESTAMP -> Value.of(timestampText(value, targetSqlType)); + case Types.DATALINK -> Value.of(datalinkText(value)); + case Types.ARRAY -> Value.of(arrayText(value)); + case Types.STRUCT -> Value.of(structText(value)); + case Types.REF -> Value.of(refText(value)); + case Types.JAVA_OBJECT -> Value.of(javaObjectText(value)); + case Types.ROWID -> Value.of(rowIdText(value)); + case Types.SQLXML -> Value.of(sqlXmlText(value)); + case Types.TIME_WITH_TIMEZONE -> Value.of(offsetTimeText(value)); + case Types.TIMESTAMP_WITH_TIMEZONE -> Value.of(offsetDateTimeText(value)); + case Types.DISTINCT -> Value.of(value.toString()); + default -> throw unsupported(value, targetSqlType); + }; + } + + private static long bigIntegerLong(BigInteger value) throws SQLException { + try { + return value.longValueExact(); + } catch (ArithmeticException e) { + throw new SQLException("BigInteger value does not fit a BIGINT: " + value, e); + } + } + + /** + * Narrows a numeric value with the Java primitive conversion rules. {@link + * BigDecimal#byteValue()} and its siblings already perform the same narrowing as a Java cast, + * so routing every numeric source through BigDecimal keeps the rules in one place. + */ + private static BigDecimal numeric(Object value, int targetSqlType) throws SQLException { + if (value instanceof BigDecimal decimal) { + return decimal; + } + if (value instanceof Boolean bool) { + return bool ? BigDecimal.ONE : BigDecimal.ZERO; + } + if (value instanceof Byte || value instanceof Short + || value instanceof Integer || value instanceof Long) { + return BigDecimal.valueOf(((Number) value).longValue()); + } + if (value instanceof Float || value instanceof Double) { + return BigDecimal.valueOf(((Number) value).doubleValue()); + } + if (value instanceof String text) { + String trimmed = text.trim(); + if ("true".equalsIgnoreCase(trimmed)) { + return BigDecimal.ONE; + } + if ("false".equalsIgnoreCase(trimmed)) { + return BigDecimal.ZERO; + } + try { + return new BigDecimal(trimmed); + } catch (NumberFormatException e) { + throw new SQLException( + "Cannot convert \"" + text + "\" to " + JdbcTypeMapping.sqlTypeName(targetSqlType), + e); + } + } + throw unsupported(value, targetSqlType); + } + + private static long bigInt(Object value) throws SQLException { + if (value instanceof BigInteger integer) { + return bigIntegerLong(integer); + } + return numeric(value, Types.BIGINT).longValue(); + } + + private static long bit(Object value, int targetSqlType) throws SQLException { + return numeric(value, targetSqlType).signum() == 0 ? 0L : 1L; + } + + private static String text(Object value, int targetSqlType) throws SQLException { + if (value instanceof String text) { + return text; + } + if (value instanceof Boolean bool) { + return bool ? "true" : "false"; + } + if (value instanceof BigDecimal || value instanceof BigInteger + || value instanceof Byte || value instanceof Short + || value instanceof Integer || value instanceof Long + || value instanceof Float || value instanceof Double) { + return value.toString(); + } + if (value instanceof java.util.Date + || value instanceof Calendar + || value instanceof LocalDate + || value instanceof LocalTime + || value instanceof LocalDateTime + || value instanceof OffsetTime + || value instanceof OffsetDateTime) { + return value.toString(); + } + throw unsupported(value, targetSqlType); + } + + private static String nationalText(Object value, int targetSqlType) throws SQLException { + if (value instanceof String text) { + return text; + } + throw unsupported(value, targetSqlType); + } + + private static byte[] binary(Object value, int targetSqlType) throws SQLException { + if (value instanceof byte[] bytes) { + return bytes; + } + if (value instanceof String text) { + return text.getBytes(StandardCharsets.UTF_8); + } + throw unsupported(value, targetSqlType); + } + + private static byte[] blobBytes(Object value) throws SQLException { + if (value instanceof Blob blob) { + return binaryBytes(blob); + } + throw unsupported(value, Types.BLOB); + } + + private static String clobText(Object value) throws SQLException { + // NClob is a Clob, but Table B-5 lists it only for the NCLOB target. + if (value instanceof Clob clob && !(value instanceof NClob)) { + return characterText(clob); + } + throw unsupported(value, Types.CLOB); + } + + private static String nclobText(Object value) throws SQLException { + if (value instanceof NClob clob) { + return characterText(clob); + } + throw unsupported(value, Types.NCLOB); + } + + private static String dateText(Object value, int targetSqlType) throws SQLException { + LocalDate date; + if (value instanceof Timestamp timestamp) { + date = timestamp.toLocalDateTime().toLocalDate(); + } else if (value instanceof Date sqlDate) { + date = sqlDate.toLocalDate(); + } else if (value instanceof LocalDate localDate) { + date = localDate; + } else if (value instanceof LocalDateTime localDateTime) { + date = localDateTime.toLocalDate(); + } else if (value instanceof java.sql.Time) { + throw unsupported(value, targetSqlType); + } else if (value instanceof Calendar calendar) { + date = LocalDate.ofInstant(calendar.toInstant(), ZoneId.systemDefault()); + } else if (value instanceof java.util.Date utilDate) { + date = LocalDate.ofInstant(utilDate.toInstant(), ZoneId.systemDefault()); + } else if (value instanceof String text) { + // A string is already in the target type's text form. Binding it unchanged keeps the + // B-5 acceptance rule and lets the getter reject malformed text. + return text; + } else { + throw unsupported(value, targetSqlType); + } + return date.toString(); + } + + private static String timeText(Object value, int targetSqlType) throws SQLException { + LocalTime time; + if (value instanceof Time sqlTime) { + time = sqlTime.toLocalTime(); + } else if (value instanceof Timestamp timestamp) { + time = timestamp.toLocalDateTime().toLocalTime(); + } else if (value instanceof LocalTime localTime) { + time = localTime; + } else if (value instanceof LocalDateTime localDateTime) { + time = localDateTime.toLocalTime(); + } else if (value instanceof Date) { + throw unsupported(value, targetSqlType); + } else if (value instanceof Calendar calendar) { + time = LocalTime.ofInstant(calendar.toInstant(), ZoneId.systemDefault()); + } else if (value instanceof java.util.Date utilDate) { + time = LocalTime.ofInstant(utilDate.toInstant(), ZoneId.systemDefault()); + } else if (value instanceof String text) { + return text; + } else { + throw unsupported(value, targetSqlType); + } + return formatTime(time); + } + + private static String timestampText(Object value, int targetSqlType) throws SQLException { + LocalDateTime dateTime; + if (value instanceof Timestamp timestamp) { + dateTime = timestamp.toLocalDateTime(); + } else if (value instanceof Date date) { + dateTime = date.toLocalDate().atStartOfDay(); + } else if (value instanceof LocalDateTime localDateTime) { + dateTime = localDateTime; + } else if (value instanceof Time time) { + // The date part of a TIME to TIMESTAMP conversion is implementation defined; + // sqlite-jdbc and this driver use the epoch date. + dateTime = LocalDate.of(1970, 1, 1).atTime(time.toLocalTime()); + } else if (value instanceof Calendar calendar) { + dateTime = localDateTime(calendar.toInstant()); + } else if (value instanceof java.util.Date utilDate) { + dateTime = localDateTime(utilDate.toInstant()); + } else if (value instanceof String text) { + return text; + } else { + throw unsupported(value, targetSqlType); + } + return formatTimestamp(dateTime); + } + + private static String datalinkText(Object value) throws SQLException { + if (value instanceof URL url) { + return url.toString(); + } + throw unsupported(value, Types.DATALINK); + } + + private static String arrayText(Object value) throws SQLException { + if (value instanceof Array array) { + return array.toString(); + } + // Table B-5 lists ARRAY for Calendar and java.util.Date, but not for their SQL subclasses. + if (value instanceof Calendar || value.getClass() == java.util.Date.class) { + return value.toString(); + } + throw unsupported(value, Types.ARRAY); + } + + private static String structText(Object value) throws SQLException { + if (value instanceof Struct struct) { + return struct.toString(); + } + throw unsupported(value, Types.STRUCT); + } + + private static String refText(Object value) throws SQLException { + if (value instanceof Ref ref) { + return ref.toString(); + } + throw unsupported(value, Types.REF); + } + + private static String rowIdText(Object value) throws SQLException { + if (value instanceof RowId rowId) { + return rowId.toString(); + } + throw unsupported(value, Types.ROWID); + } + + private static String sqlXmlText(Object value) throws SQLException { + if (value instanceof SQLXML xml) { + return xml.toString(); + } + throw unsupported(value, Types.SQLXML); + } + + private static String javaObjectText(Object value) throws SQLException { + if (knownSourceFamily(value)) { + throw unsupported(value, Types.JAVA_OBJECT); + } + return value.toString(); + } + + private static String offsetTimeText(Object value) throws SQLException { + if (value instanceof OffsetTime time) { + return time.toString(); + } + if (value instanceof OffsetDateTime dateTime) { + return dateTime.toOffsetTime().toString(); + } + throw unsupported(value, Types.TIME_WITH_TIMEZONE); + } + + private static String offsetDateTimeText(Object value) throws SQLException { + if (value instanceof OffsetDateTime dateTime) { + return dateTime.toString(); + } + throw unsupported(value, Types.TIMESTAMP_WITH_TIMEZONE); + } + + private static boolean knownSourceFamily(Object value) { + return value instanceof String + || value instanceof Number + || value instanceof Boolean + || value instanceof byte[] + || value instanceof java.util.Date + || value instanceof Calendar + || value instanceof LocalDate + || value instanceof LocalTime + || value instanceof LocalDateTime + || value instanceof OffsetTime + || value instanceof OffsetDateTime + || value instanceof Clob + || value instanceof Blob + || value instanceof Array + || value instanceof Struct + || value instanceof Ref + || value instanceof URL + || value instanceof RowId + || value instanceof SQLXML; + } + + private static LocalDateTime localDateTime(java.time.Instant instant) { + return LocalDateTime.ofInstant(instant, ZoneId.systemDefault()); + } + + private static String formatTimestamp(LocalDateTime value) { + return value.toLocalDate() + " " + formatTime(value.toLocalTime()); + } + + private static String formatTime(LocalTime value) { + StringBuilder text = new StringBuilder(18); + appendTwoDigits(text, value.getHour()); + text.append(':'); + appendTwoDigits(text, value.getMinute()); + text.append(':'); + appendTwoDigits(text, value.getSecond()); + int nanos = value.getNano(); + if (nanos != 0) { + String fraction = Integer.toString(1_000_000_000 + nanos).substring(1); + int end = fraction.length(); + while (end > 0 && fraction.charAt(end - 1) == '0') { + end--; + } + text.append('.').append(fraction, 0, end); + } + return text.toString(); + } + + private static void appendTwoDigits(StringBuilder text, int value) { + text.append((char) ('0' + value / 10)).append((char) ('0' + value % 10)); + } + + private static String characterText(Clob clob) throws SQLException { + Reader reader = clob.getCharacterStream(); + 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 byte[] binaryBytes(Blob blob) throws SQLException { + InputStream in = blob.getBinaryStream(); + 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 SQLFeatureNotSupportedException unsupported(Object value, int targetSqlType) { + return new SQLFeatureNotSupportedException( + "Cannot convert " + value.getClass().getName() + " to " + + JdbcTypeMapping.sqlTypeName(targetSqlType)); + } +} From 4ae213a99488b948ac47894e439fc841b1dcd88e Mon Sep 17 00:00:00 2001 From: Magnus Persson <1431685+magnusp@users.noreply.github.com> Date: Sun, 25 Oct 2026 03:09:52 +0100 Subject: [PATCH 2/3] feat(jdbc): convert setObject values to the target JDBC type setObject(x, targetSqlType) now converts to the target type before binding, instead of ignoring the target and delegating to setObject(x). --- .../libsql/jdbc/LibsqlPreparedStatement.java | 27 +++---------------- 1 file changed, 4 insertions(+), 23 deletions(-) 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 2176622..64ad113 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 @@ -258,37 +258,18 @@ public void setTimestamp(int parameterIndex, Timestamp x) throws SQLException { @Override public void setObject(int parameterIndex, Object x) throws SQLException { - if (x == null) { - setNull(parameterIndex, 0); - } else if (x instanceof String s) { - setString(parameterIndex, s); - } else if (x instanceof Long l) { - setLong(parameterIndex, l); - } else if (x instanceof Integer i) { - setInt(parameterIndex, i); - } else if (x instanceof Double d) { - setDouble(parameterIndex, d); - } else if (x instanceof Float f) { - setFloat(parameterIndex, f); - } else if (x instanceof Boolean b) { - setBoolean(parameterIndex, b); - } else if (x instanceof byte[] bytes) { - setBytes(parameterIndex, bytes); - } else if (x instanceof BigDecimal bd) { - setBigDecimal(parameterIndex, bd); - } else { - setString(parameterIndex, x.toString()); - } + setParam(parameterIndex, JdbcValueConverter.toValue(x)); } @Override public void setObject(int parameterIndex, Object x, int targetSqlType) throws SQLException { - setObject(parameterIndex, x); + setParam(parameterIndex, JdbcValueConverter.toValue(x, targetSqlType)); } @Override public void setObject(int parameterIndex, Object x, int targetSqlType, int scaleOrLength) throws SQLException { - setObject(parameterIndex, x); + // Hrana has no scale or length for a parameter; the target type still decides the value. + setParam(parameterIndex, JdbcValueConverter.toValue(x, targetSqlType)); } @Override From 0ea436c540a4b16e156d3e03e5b9cf38424b4f1b Mon Sep 17 00:00:00 2001 From: Magnus Persson <1431685+magnusp@users.noreply.github.com> Date: Sun, 25 Oct 2026 03:09:52 +0100 Subject: [PATCH 3/3] test(jdbc): pin setObject conversions per target family Each Table B-4 and B-5 family asserts the Hrana value kind and the Boolean text format without a server. --- .../jdbc/LibsqlPreparedStatementTest.java | 153 ++++++++++++++++++ 1 file changed, 153 insertions(+) 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 8546e2c..8446d83 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 @@ -9,14 +9,26 @@ import java.io.ByteArrayInputStream; import java.io.StringReader; +import java.math.BigDecimal; +import java.math.BigInteger; import java.nio.charset.StandardCharsets; +import java.sql.Date; +import java.sql.NClob; import java.sql.ParameterMetaData; import java.sql.ResultSet; import java.sql.SQLException; import java.sql.SQLFeatureNotSupportedException; +import java.sql.Time; +import java.sql.Timestamp; import java.sql.Types; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.ZoneId; import java.util.ArrayList; +import java.util.Calendar; import java.util.List; +import java.util.TimeZone; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -448,6 +460,147 @@ void streamSettersWithoutALengthReadToEndOfStream() throws SQLException { Value.of("ascii")); } + @Test + void integralTargetsBindHranaIntegers() throws SQLException { + assertThat(bind("42", Types.TINYINT)).isEqualTo(Value.of(42L)); + assertThat(bind((short) 42, Types.SMALLINT)).isEqualTo(Value.of(42L)); + assertThat(bind(42L, Types.INTEGER)).isEqualTo(Value.of(42L)); + assertThat(bind(BigInteger.valueOf(42), Types.BIGINT)).isEqualTo(Value.of(42L)); + // Java narrowing applies to an integral target. + assertThat(bind(258, Types.TINYINT)).isEqualTo(Value.of(2L)); + } + + @Test + void realTargetsBindHranaFloats() throws SQLException { + assertThat(bind("42", Types.REAL)).isEqualTo(Value.of(42.0)); + assertThat(bind(42L, Types.FLOAT)).isEqualTo(Value.of(42.0)); + assertThat(bind(42L, Types.DOUBLE)).isEqualTo(Value.of(42.0)); + } + + @Test + void decimalTargetsBindDecimalText() throws SQLException { + assertThat(bind(new BigDecimal("7"), Types.DECIMAL)).isEqualTo(Value.of("7")); + assertThat(bind(7, Types.NUMERIC)).isEqualTo(Value.of("7")); + } + + @Test + void booleanTargetsBindZeroOrOneIntegers() throws SQLException { + assertThat(bind(true, Types.BIT)).isEqualTo(Value.of(1L)); + assertThat(bind("false", Types.BOOLEAN)).isEqualTo(Value.of(0L)); + assertThat(bind(new BigDecimal("0.5"), Types.BOOLEAN)).isEqualTo(Value.of(1L)); + } + + @Test + void textTargetsBindTheJavaTextForm() throws SQLException { + assertThat(bind(7, Types.VARCHAR)).isEqualTo(Value.of("7")); + assertThat(bind("x", Types.NCHAR)).isEqualTo(Value.of("x")); + assertThat(bind("x", Types.LONGNVARCHAR)).isEqualTo(Value.of("x")); + // The documented Boolean text format is "true" / "false", not "TRUE" / "FALSE". + assertThat(bind(true, Types.CHAR)).isEqualTo(Value.of("true")); + assertThat(bind(false, Types.VARCHAR)).isEqualTo(Value.of("false")); + } + + @Test + void binaryTargetsBindHranaBlobs() throws SQLException { + byte[] bytes = {1, 2, 3}; + assertThat(bind(bytes, Types.VARBINARY)).isEqualTo(Value.of(bytes)); + assertThat(bind("abc", Types.BINARY)) + .isEqualTo(Value.of("abc".getBytes(StandardCharsets.UTF_8))); + assertThat(bind(new LibsqlBlob(bytes), Types.BLOB)).isEqualTo(Value.of(bytes)); + } + + @Test + void temporalTargetsBindSqliteText() throws SQLException { + Calendar calendar = calendar2024(); + java.util.Date utilDate = new java.util.Date(calendar.getTimeInMillis()); + String expectedTimestamp = timestampText(LocalDateTime.ofInstant( + calendar.toInstant(), ZoneId.systemDefault())); + + assertThat(bind(Date.valueOf("2024-01-02"), Types.DATE)) + .isEqualTo(Value.of("2024-01-02")); + assertThat(bind(Time.valueOf("03:04:05"), Types.TIME)) + .isEqualTo(Value.of("03:04:05")); + assertThat(bind(Timestamp.valueOf("2024-01-02 03:04:05"), Types.TIMESTAMP)) + .isEqualTo(Value.of("2024-01-02 03:04:05")); + assertThat(bind(Timestamp.valueOf("2024-01-02 03:04:05"), Types.DATE)) + .isEqualTo(Value.of("2024-01-02")); + assertThat(bind(LocalDateTime.of(2024, 1, 2, 3, 4, 5), Types.TIME)) + .isEqualTo(Value.of("03:04:05")); + assertThat(bind(calendar, Types.TIMESTAMP)).isEqualTo(Value.of(expectedTimestamp)); + assertThat(bind(utilDate, Types.TIMESTAMP)).isEqualTo(Value.of(expectedTimestamp)); + } + + @Test + void lobTargetsReadTheirStreams() throws SQLException { + byte[] bytes = {1, 2, 3}; + assertThat(bind(new LibsqlClob("abc"), Types.CLOB)).isEqualTo(Value.of("abc")); + assertThat(bind(new LibsqlBlob(bytes), Types.BLOB)).isEqualTo(Value.of(bytes)); + assertThat(bind(nclob("nclob"), Types.NCLOB)).isEqualTo(Value.of("nclob")); + } + + @Test + void unlistedTargetPairThrowsFeatureNotSupported() { + assertThatThrownBy(() -> bind("42", Types.ARRAY)) + .isInstanceOf(SQLFeatureNotSupportedException.class) + .hasMessageContaining("ARRAY"); + assertThatThrownBy(() -> bind(7, Types.BLOB)) + .isInstanceOf(SQLFeatureNotSupportedException.class) + .hasMessageContaining("BLOB"); + } + + @Test + void setObjectWithoutATargetFollowsTableB4() throws SQLException { + assertThat(bind(BigInteger.valueOf(7))).isEqualTo(Value.of(7L)); + assertThat(bind((byte) 7)).isEqualTo(Value.of(7L)); + assertThat(bind((short) 7)).isEqualTo(Value.of(7L)); + assertThat(bind(true)).isEqualTo(Value.of(1L)); + assertThat(bind(LocalDate.of(2024, 1, 2))).isEqualTo(Value.of("2024-01-02")); + assertThat(bind(LocalDateTime.of(2024, 1, 2, 3, 4, 5))) + .isEqualTo(Value.of("2024-01-02 03:04:05")); + } + + private static Value bind(Object value) throws SQLException { + RecordingConnection connection = new RecordingConnection(NO_ROWS); + try (LibsqlPreparedStatement statement = + new LibsqlPreparedStatement(connection, "insert into t values (?)")) { + statement.setObject(1, value); + statement.executeUpdate(); + } + return connection.executed.get(0).args().get(0); + } + + private static Value bind(Object value, int targetSqlType) throws SQLException { + RecordingConnection connection = new RecordingConnection(NO_ROWS); + try (LibsqlPreparedStatement statement = + new LibsqlPreparedStatement(connection, "insert into t values (?)")) { + statement.setObject(1, value, targetSqlType); + statement.executeUpdate(); + } + return connection.executed.get(0).args().get(0); + } + + private static Calendar calendar2024() { + Calendar calendar = Calendar.getInstance(TimeZone.getTimeZone("UTC")); + calendar.clear(); + calendar.set(2024, Calendar.JANUARY, 2, 3, 4, 5); + return calendar; + } + + private static String timestampText(LocalDateTime value) { + return value.toLocalDate() + " " + String.format( + "%02d:%02d:%02d", + value.getHour(), value.getMinute(), value.getSecond()); + } + + private static NClob nclob(String text) { + return (NClob) java.lang.reflect.Proxy.newProxyInstance( + NClob.class.getClassLoader(), + new Class[] {NClob.class}, + (proxy, method, args) -> method.getName().equals("getCharacterStream") + ? new StringReader(text) + : null); + } + private static final class RecordingConnection extends LibsqlConnection { private final StmtResult result; private final List executedSql = new ArrayList<>();