From bc8c6367b5faec5da43f1d7d6e2103d5f37dfd84 Mon Sep 17 00:00:00 2001 From: Magnus Persson <1431685+magnusp@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:45:32 +0200 Subject: [PATCH] fix(jdbc): gate getObject numeric targets on storage class Closes #34. Part of #54. Matches sqlite-jdbc 3.53.4.0: Integer, Long, Float, and Double convert only from INTEGER or FLOAT storage and throw 'Bad value for type X' otherwise, including NULL, TEXT, and BLOB. Short and Byte are no longer supported, and unsupported target types use SQLFeatureNotSupportedException. The target-type by storage-class matrix is documented on the method. --- .../magnusp/libsql/jdbc/LibsqlResultSet.java | 179 +++++++++--------- .../jdbc/LibsqlResultSetGetObjectTest.java | 164 ++++++++++++++++ .../libsql/jdbc/LibsqlResultSetTest.java | 18 +- 3 files changed, 263 insertions(+), 98 deletions(-) create mode 100644 libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlResultSetGetObjectTest.java diff --git a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlResultSet.java b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlResultSet.java index 84dfa9c..69a58a2 100644 --- a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlResultSet.java +++ b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlResultSet.java @@ -20,6 +20,7 @@ import java.sql.ResultSetMetaData; import java.sql.RowId; import java.sql.SQLException; +import java.sql.SQLFeatureNotSupportedException; import java.sql.SQLWarning; import java.sql.SQLXML; import java.sql.Statement; @@ -481,70 +482,6 @@ private static double toDouble(Value val) { }; } - private static int toStrictInt(Value val) throws SQLException { - return switch (val) { - case Value.Null n -> 0; - case Value.Integer i -> (int) i.value(); - case Value.Float f -> (int) f.value(); - case Value.Text t -> { - try { - yield Integer.parseInt(t.value()); - } catch (NumberFormatException e) { - throw new SQLException("Bad value for type Integer: " + t.value(), e); - } - } - case Value.Blob b -> 0; - }; - } - - private static long toStrictLong(Value val) throws SQLException { - return switch (val) { - case Value.Null n -> 0L; - case Value.Integer i -> i.value(); - case Value.Float f -> (long) f.value(); - case Value.Text t -> { - try { - yield Long.parseLong(t.value()); - } catch (NumberFormatException e) { - throw new SQLException("Bad value for type Long: " + t.value(), e); - } - } - case Value.Blob b -> 0L; - }; - } - - private static float toStrictFloat(Value val) throws SQLException { - return switch (val) { - case Value.Null n -> 0.0F; - case Value.Integer i -> (float) i.value(); - case Value.Float f -> (float) f.value(); - case Value.Text t -> { - try { - yield Float.parseFloat(t.value()); - } catch (NumberFormatException e) { - throw new SQLException("Bad value for type Float: " + t.value(), e); - } - } - case Value.Blob b -> 0.0F; - }; - } - - private static double toStrictDouble(Value val) throws SQLException { - return switch (val) { - case Value.Null n -> 0.0; - case Value.Integer i -> (double) i.value(); - case Value.Float f -> f.value(); - case Value.Text t -> { - try { - yield Double.parseDouble(t.value()); - } catch (NumberFormatException e) { - throw new SQLException("Bad value for type Double: " + t.value(), e); - } - } - case Value.Blob b -> 0.0; - }; - } - private static String blobAsText(Value.Blob b) { return new String(b.bytes(), java.nio.charset.StandardCharsets.UTF_8); } @@ -1031,18 +968,65 @@ public boolean isLast() throws SQLException { @Override public void updateNClob(int columnIndex, Reader reader) throws SQLException { throw new UnsupportedOperationException(); } @Override public void updateNClob(String columnLabel, Reader reader) throws SQLException { throw new UnsupportedOperationException(); } + /** + * Returns a column value converted to the requested Java type. + * + *

The conversion matrix matches sqlite-jdbc 3.53.4.0. Numeric wrapper targets are gated on + * the current value's SQLite storage class; any other storage class raises {@code Bad value + * for type Integer} (or the corresponding target type name): + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + *
Target type by source storage class
Target typeINTEGERFLOATTEXTBLOBNULL
{@code Integer}, {@code Long}, {@code Float}, {@code Double}convertsconvertsbad valuebad valuebad value
{@code String}canonical textcanonical texttextUTF-8 text{@code null}
{@code Boolean}nonzerononzerointeger coercioninteger coercion{@code false}
{@code BigDecimal}decimal textdecimal textdecimal textdecimal text{@code null}
{@code byte[]}UTF-8 textUTF-8 textUTF-8 textbytes{@code null}
{@code Date}, {@code Time}, {@code Timestamp}, {@code LocalDate}, {@code LocalTime}, + * {@code LocalDateTime}text parsetext parsetext parsetext parse{@code null}
{@code Short}, {@code Byte}, any other typeunsupported
+ * + *

{@code Short} and {@code Byte} are rejected rather than narrowed from the numeric + * targets, preserving the sqlite-jdbc 3.53.4.0 contract. + * + * @throws SQLException if {@code type} is {@code null}, a numeric target reads a value from + * any storage class other than INTEGER or FLOAT, or the value cannot be converted + * @throws SQLFeatureNotSupportedException if {@code type} is not a supported target type + */ @Override public T getObject(int columnIndex, Class type) throws SQLException { if (type == null) { - throw new SQLException("Type must not be null"); - } - Object val = getObject(columnIndex); - if (val == null) { - return null; - } - Value stored = getCurrentValue(columnIndex); - if (type.isInstance(val)) { - return type.cast(val); + throw new SQLException("requested type cannot be null"); } if (type == String.class) { return type.cast(getString(columnIndex)); @@ -1056,24 +1040,6 @@ public T getObject(int columnIndex, Class type) throws SQLException { if (type == byte[].class) { return type.cast(getBytes(columnIndex)); } - if (type == Double.class) { - return type.cast(toStrictDouble(stored)); - } - if (type == Long.class) { - return type.cast(toStrictLong(stored)); - } - if (type == Float.class) { - return type.cast(toStrictFloat(stored)); - } - if (type == Integer.class) { - return type.cast(toStrictInt(stored)); - } - if (type == Short.class) { - return type.cast(Short.valueOf((short) toStrictLong(stored))); - } - if (type == Byte.class) { - return type.cast(Byte.valueOf((byte) toStrictLong(stored))); - } if (type == Date.class) { return type.cast(getDate(columnIndex)); } @@ -1092,8 +1058,35 @@ public T getObject(int columnIndex, Class type) throws SQLException { if (type == LocalDateTime.class) { return type.cast(getLocalDateTime(columnIndex)); } - throw new SQLException( - "Cannot convert value of type " + val.getClass() + " to " + type.getName()); + + Value stored = getCurrentValue(columnIndex); + boolean numeric = stored instanceof Value.Integer || stored instanceof Value.Float; + if (type == Double.class) { + if (numeric) { + return type.cast(getDouble(columnIndex)); + } + throw new SQLException("Bad value for type Double"); + } + if (type == Long.class) { + if (numeric) { + return type.cast(getLong(columnIndex)); + } + throw new SQLException("Bad value for type Long"); + } + if (type == Float.class) { + if (numeric) { + return type.cast(getFloat(columnIndex)); + } + throw new SQLException("Bad value for type Float"); + } + if (type == Integer.class) { + if (numeric) { + return type.cast(getInt(columnIndex)); + } + throw new SQLException("Bad value for type Integer"); + } + + throw new SQLFeatureNotSupportedException("not implemented by SQLite JDBC driver"); } private LocalDate getLocalDate(int columnIndex) throws SQLException { diff --git a/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlResultSetGetObjectTest.java b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlResultSetGetObjectTest.java new file mode 100644 index 0000000..74ed065 --- /dev/null +++ b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlResultSetGetObjectTest.java @@ -0,0 +1,164 @@ +package com.github.magnusp.libsql.jdbc; + +import com.github.magnusp.libsql.hrana.codec.Col; +import com.github.magnusp.libsql.hrana.codec.Row; +import com.github.magnusp.libsql.hrana.codec.StmtResult; +import com.github.magnusp.libsql.hrana.codec.Value; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.nio.charset.StandardCharsets; +import java.sql.SQLException; +import java.sql.SQLFeatureNotSupportedException; +import java.util.List; +import java.util.UUID; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class LibsqlResultSetGetObjectTest { + + static Stream numericTargets() { + return Stream.of( + Arguments.of(Integer.class, "Integer", 7, 7), + Arguments.of(Long.class, "Long", 7L, 7L), + Arguments.of(Float.class, "Float", 7.0F, 7.5F), + Arguments.of(Double.class, "Double", 7.0, 7.5)); + } + + static Stream nonIntegerOrFloatStorage() { + return Stream.of( + Arguments.of("numeric text", new Value.Text("7")), + Arguments.of("non-numeric text", new Value.Text("abc")), + Arguments.of("numeric blob", new Value.Blob("123".getBytes(StandardCharsets.UTF_8))), + Arguments.of("non-numeric blob", new Value.Blob("abc".getBytes(StandardCharsets.UTF_8))), + Arguments.of("null", new Value.Null())); + } + + @ParameterizedTest(name = "{1} from INTEGER storage") + @MethodSource("numericTargets") + void numericTargetsReadIntegerStorage( + Class target, String typeName, Object fromInteger, Object fromFloat) throws SQLException { + LibsqlResultSet rs = resultSet(new Value.Integer(7)); + assertThat(rs.next()).isTrue(); + + assertThat(rs.getObject(1, target)).isEqualTo(fromInteger); + } + + @ParameterizedTest(name = "{1} from FLOAT storage") + @MethodSource("numericTargets") + void numericTargetsReadFloatStorage( + Class target, String typeName, Object fromInteger, Object fromFloat) throws SQLException { + LibsqlResultSet rs = resultSet(new Value.Float(7.5)); + assertThat(rs.next()).isTrue(); + + assertThat(rs.getObject(1, target)).isEqualTo(fromFloat); + } + + @ParameterizedTest(name = "{1} rejects {0}") + @MethodSource("numericTargets") + void numericTargetsRejectTextBlobAndNullStorage(Class target, String typeName) + throws SQLException { + for (Arguments arguments : nonIntegerOrFloatStorage().toList()) { + Object[] parts = arguments.get(); + Value value = (Value) parts[1]; + + LibsqlResultSet rs = resultSet(value); + assertThat(rs.next()).isTrue(); + + assertThatThrownBy(() -> rs.getObject(1, target)) + .as("%s from %s storage", typeName, parts[0]) + .isExactlyInstanceOf(SQLException.class) + .hasMessage("Bad value for type " + typeName); + } + } + + static Stream shortAndByteTargets() { + return Stream.of(Arguments.of(Short.class, "Short"), Arguments.of(Byte.class, "Byte")); + } + + @ParameterizedTest(name = "{1} is unsupported") + @MethodSource("shortAndByteTargets") + void shortAndByteAreUnsupportedForEveryStorageClass(Class target, String typeName) + throws SQLException { + for (Arguments arguments : nonIntegerOrFloatStorage().toList()) { + Object[] parts = arguments.get(); + Value value = (Value) parts[1]; + + LibsqlResultSet rs = resultSet(value); + assertThat(rs.next()).isTrue(); + + assertThatThrownBy(() -> rs.getObject(1, target)) + .as("%s from %s storage", typeName, parts[0]) + .isExactlyInstanceOf(SQLFeatureNotSupportedException.class) + .hasMessage("not implemented by SQLite JDBC driver"); + } + + for (Value value : List.of(new Value.Integer(7), new Value.Float(7.5))) { + LibsqlResultSet rs = resultSet(value); + assertThat(rs.next()).isTrue(); + + assertThatThrownBy(() -> rs.getObject(1, target)) + .as("%s from %s storage", typeName, value.getClass().getSimpleName()) + .isExactlyInstanceOf(SQLFeatureNotSupportedException.class) + .hasMessage("not implemented by SQLite JDBC driver"); + } + } + + static Stream unsupportedTargets() { + return Stream.of( + Arguments.of(Object.class), + Arguments.of(Number.class), + Arguments.of(UUID.class)); + } + + @ParameterizedTest(name = "{0} is unsupported") + @MethodSource("unsupportedTargets") + void unsupportedTargetTypeUsesXerialMessage(Class target) throws SQLException { + LibsqlResultSet rs = resultSet(new Value.Integer(7)); + assertThat(rs.next()).isTrue(); + + assertThatThrownBy(() -> rs.getObject(1, target)) + .isExactlyInstanceOf(SQLFeatureNotSupportedException.class) + .hasMessage("not implemented by SQLite JDBC driver"); + } + + @Test + void nullTargetTypeUsesXerialMessage() throws SQLException { + LibsqlResultSet rs = resultSet(new Value.Integer(7)); + assertThat(rs.next()).isTrue(); + + assertThatThrownBy(() -> rs.getObject(1, (Class) null)) + .isExactlyInstanceOf(SQLException.class) + .hasMessage("requested type cannot be null"); + } + + @Test + void nonNumericTargetsKeepTheirUnrestrictedConversions() throws SQLException { + LibsqlResultSet integers = resultSet(new Value.Integer(7)); + assertThat(integers.next()).isTrue(); + assertThat(integers.getObject(1, String.class)).isEqualTo("7"); + assertThat(integers.getObject(1, byte[].class)) + .isEqualTo("7".getBytes(StandardCharsets.UTF_8)); + + LibsqlResultSet text = resultSet(new Value.Text("abc")); + assertThat(text.next()).isTrue(); + assertThat(text.getObject(1, String.class)).isEqualTo("abc"); + assertThat(text.getObject(1, Boolean.class)).isFalse(); + + LibsqlResultSet nullValue = resultSet(new Value.Null()); + assertThat(nullValue.next()).isTrue(); + assertThat(nullValue.getObject(1, String.class)).isNull(); + assertThat(nullValue.getObject(1, Boolean.class)).isFalse(); + assertThat(nullValue.getObject(1, byte[].class)).isNull(); + } + + private static LibsqlResultSet resultSet(Value value) { + List cols = List.of(new Col("c", null)); + StmtResult result = new StmtResult(cols, List.of(new Row(List.of(value))), 1, null); + return new LibsqlResultSet(null, result); + } +} diff --git a/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlResultSetTest.java b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlResultSetTest.java index f2b3a5a..8e556db 100644 --- a/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlResultSetTest.java +++ b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlResultSetTest.java @@ -176,14 +176,22 @@ void getObjectNumericConversionsStayStrictForNonNumericText() throws SQLExceptio } @Test - void getObjectNumericConversionsAcceptNumericText() throws SQLException { + void getObjectNumericConversionsRejectNumericTextLikeXerial() throws SQLException { LibsqlResultSet rs = resultSet(new Value.Text("123")); assertThat(rs.next()).isTrue(); - assertThat(rs.getObject(1, Double.class)).isEqualTo(123.0); - assertThat(rs.getObject(1, Long.class)).isEqualTo(123L); - assertThat(rs.getObject(1, Float.class)).isEqualTo(123.0f); - assertThat(rs.getObject(1, Integer.class)).isEqualTo(123); + assertThatThrownBy(() -> rs.getObject(1, Double.class)) + .isInstanceOf(SQLException.class) + .hasMessage("Bad value for type Double"); + assertThatThrownBy(() -> rs.getObject(1, Long.class)) + .isInstanceOf(SQLException.class) + .hasMessage("Bad value for type Long"); + assertThatThrownBy(() -> rs.getObject(1, Float.class)) + .isInstanceOf(SQLException.class) + .hasMessage("Bad value for type Float"); + assertThatThrownBy(() -> rs.getObject(1, Integer.class)) + .isInstanceOf(SQLException.class) + .hasMessage("Bad value for type Integer"); } private static void assertNumeric(LibsqlResultSet rs, int column, int expectedInt, long expectedLong,