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,