Skip to content

Commit bc8c636

Browse files
committed
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.
1 parent 506f595 commit bc8c636

3 files changed

Lines changed: 263 additions & 98 deletions

File tree

‎libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlResultSet.java‎

Lines changed: 86 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import java.sql.ResultSetMetaData;
2121
import java.sql.RowId;
2222
import java.sql.SQLException;
23+
import java.sql.SQLFeatureNotSupportedException;
2324
import java.sql.SQLWarning;
2425
import java.sql.SQLXML;
2526
import java.sql.Statement;
@@ -481,70 +482,6 @@ private static double toDouble(Value val) {
481482
};
482483
}
483484

484-
private static int toStrictInt(Value val) throws SQLException {
485-
return switch (val) {
486-
case Value.Null n -> 0;
487-
case Value.Integer i -> (int) i.value();
488-
case Value.Float f -> (int) f.value();
489-
case Value.Text t -> {
490-
try {
491-
yield Integer.parseInt(t.value());
492-
} catch (NumberFormatException e) {
493-
throw new SQLException("Bad value for type Integer: " + t.value(), e);
494-
}
495-
}
496-
case Value.Blob b -> 0;
497-
};
498-
}
499-
500-
private static long toStrictLong(Value val) throws SQLException {
501-
return switch (val) {
502-
case Value.Null n -> 0L;
503-
case Value.Integer i -> i.value();
504-
case Value.Float f -> (long) f.value();
505-
case Value.Text t -> {
506-
try {
507-
yield Long.parseLong(t.value());
508-
} catch (NumberFormatException e) {
509-
throw new SQLException("Bad value for type Long: " + t.value(), e);
510-
}
511-
}
512-
case Value.Blob b -> 0L;
513-
};
514-
}
515-
516-
private static float toStrictFloat(Value val) throws SQLException {
517-
return switch (val) {
518-
case Value.Null n -> 0.0F;
519-
case Value.Integer i -> (float) i.value();
520-
case Value.Float f -> (float) f.value();
521-
case Value.Text t -> {
522-
try {
523-
yield Float.parseFloat(t.value());
524-
} catch (NumberFormatException e) {
525-
throw new SQLException("Bad value for type Float: " + t.value(), e);
526-
}
527-
}
528-
case Value.Blob b -> 0.0F;
529-
};
530-
}
531-
532-
private static double toStrictDouble(Value val) throws SQLException {
533-
return switch (val) {
534-
case Value.Null n -> 0.0;
535-
case Value.Integer i -> (double) i.value();
536-
case Value.Float f -> f.value();
537-
case Value.Text t -> {
538-
try {
539-
yield Double.parseDouble(t.value());
540-
} catch (NumberFormatException e) {
541-
throw new SQLException("Bad value for type Double: " + t.value(), e);
542-
}
543-
}
544-
case Value.Blob b -> 0.0;
545-
};
546-
}
547-
548485
private static String blobAsText(Value.Blob b) {
549486
return new String(b.bytes(), java.nio.charset.StandardCharsets.UTF_8);
550487
}
@@ -1031,18 +968,65 @@ public boolean isLast() throws SQLException {
1031968
@Override public void updateNClob(int columnIndex, Reader reader) throws SQLException { throw new UnsupportedOperationException(); }
1032969
@Override public void updateNClob(String columnLabel, Reader reader) throws SQLException { throw new UnsupportedOperationException(); }
1033970

971+
/**
972+
* Returns a column value converted to the requested Java type.
973+
*
974+
* <p>The conversion matrix matches sqlite-jdbc 3.53.4.0. Numeric wrapper targets are gated on
975+
* the current value's SQLite storage class; any other storage class raises {@code Bad value
976+
* for type Integer} (or the corresponding target type name):
977+
*
978+
* <table>
979+
* <caption>Target type by source storage class</caption>
980+
* <tr>
981+
* <th>Target type</th><th>INTEGER</th><th>FLOAT</th><th>TEXT</th><th>BLOB</th><th>NULL</th>
982+
* </tr>
983+
* <tr>
984+
* <td>{@code Integer}, {@code Long}, {@code Float}, {@code Double}</td>
985+
* <td>converts</td><td>converts</td><td>bad value</td><td>bad value</td><td>bad value</td>
986+
* </tr>
987+
* <tr>
988+
* <td>{@code String}</td>
989+
* <td>canonical text</td><td>canonical text</td><td>text</td><td>UTF-8 text</td>
990+
* <td>{@code null}</td>
991+
* </tr>
992+
* <tr>
993+
* <td>{@code Boolean}</td>
994+
* <td>nonzero</td><td>nonzero</td><td>integer coercion</td><td>integer coercion</td>
995+
* <td>{@code false}</td>
996+
* </tr>
997+
* <tr>
998+
* <td>{@code BigDecimal}</td>
999+
* <td>decimal text</td><td>decimal text</td><td>decimal text</td><td>decimal text</td>
1000+
* <td>{@code null}</td>
1001+
* </tr>
1002+
* <tr>
1003+
* <td>{@code byte[]}</td>
1004+
* <td>UTF-8 text</td><td>UTF-8 text</td><td>UTF-8 text</td><td>bytes</td>
1005+
* <td>{@code null}</td>
1006+
* </tr>
1007+
* <tr>
1008+
* <td>{@code Date}, {@code Time}, {@code Timestamp}, {@code LocalDate}, {@code LocalTime},
1009+
* {@code LocalDateTime}</td>
1010+
* <td>text parse</td><td>text parse</td><td>text parse</td><td>text parse</td>
1011+
* <td>{@code null}</td>
1012+
* </tr>
1013+
* <tr>
1014+
* <td>{@code Short}, {@code Byte}, any other type</td>
1015+
* <td colspan="5">unsupported</td>
1016+
* </tr>
1017+
* </table>
1018+
*
1019+
* <p>{@code Short} and {@code Byte} are rejected rather than narrowed from the numeric
1020+
* targets, preserving the sqlite-jdbc 3.53.4.0 contract.
1021+
*
1022+
* @throws SQLException if {@code type} is {@code null}, a numeric target reads a value from
1023+
* any storage class other than INTEGER or FLOAT, or the value cannot be converted
1024+
* @throws SQLFeatureNotSupportedException if {@code type} is not a supported target type
1025+
*/
10341026
@Override
10351027
public <T> T getObject(int columnIndex, Class<T> type) throws SQLException {
10361028
if (type == null) {
1037-
throw new SQLException("Type must not be null");
1038-
}
1039-
Object val = getObject(columnIndex);
1040-
if (val == null) {
1041-
return null;
1042-
}
1043-
Value stored = getCurrentValue(columnIndex);
1044-
if (type.isInstance(val)) {
1045-
return type.cast(val);
1029+
throw new SQLException("requested type cannot be null");
10461030
}
10471031
if (type == String.class) {
10481032
return type.cast(getString(columnIndex));
@@ -1056,24 +1040,6 @@ public <T> T getObject(int columnIndex, Class<T> type) throws SQLException {
10561040
if (type == byte[].class) {
10571041
return type.cast(getBytes(columnIndex));
10581042
}
1059-
if (type == Double.class) {
1060-
return type.cast(toStrictDouble(stored));
1061-
}
1062-
if (type == Long.class) {
1063-
return type.cast(toStrictLong(stored));
1064-
}
1065-
if (type == Float.class) {
1066-
return type.cast(toStrictFloat(stored));
1067-
}
1068-
if (type == Integer.class) {
1069-
return type.cast(toStrictInt(stored));
1070-
}
1071-
if (type == Short.class) {
1072-
return type.cast(Short.valueOf((short) toStrictLong(stored)));
1073-
}
1074-
if (type == Byte.class) {
1075-
return type.cast(Byte.valueOf((byte) toStrictLong(stored)));
1076-
}
10771043
if (type == Date.class) {
10781044
return type.cast(getDate(columnIndex));
10791045
}
@@ -1092,8 +1058,35 @@ public <T> T getObject(int columnIndex, Class<T> type) throws SQLException {
10921058
if (type == LocalDateTime.class) {
10931059
return type.cast(getLocalDateTime(columnIndex));
10941060
}
1095-
throw new SQLException(
1096-
"Cannot convert value of type " + val.getClass() + " to " + type.getName());
1061+
1062+
Value stored = getCurrentValue(columnIndex);
1063+
boolean numeric = stored instanceof Value.Integer || stored instanceof Value.Float;
1064+
if (type == Double.class) {
1065+
if (numeric) {
1066+
return type.cast(getDouble(columnIndex));
1067+
}
1068+
throw new SQLException("Bad value for type Double");
1069+
}
1070+
if (type == Long.class) {
1071+
if (numeric) {
1072+
return type.cast(getLong(columnIndex));
1073+
}
1074+
throw new SQLException("Bad value for type Long");
1075+
}
1076+
if (type == Float.class) {
1077+
if (numeric) {
1078+
return type.cast(getFloat(columnIndex));
1079+
}
1080+
throw new SQLException("Bad value for type Float");
1081+
}
1082+
if (type == Integer.class) {
1083+
if (numeric) {
1084+
return type.cast(getInt(columnIndex));
1085+
}
1086+
throw new SQLException("Bad value for type Integer");
1087+
}
1088+
1089+
throw new SQLFeatureNotSupportedException("not implemented by SQLite JDBC driver");
10971090
}
10981091

10991092
private LocalDate getLocalDate(int columnIndex) throws SQLException {
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
package com.github.magnusp.libsql.jdbc;
2+
3+
import com.github.magnusp.libsql.hrana.codec.Col;
4+
import com.github.magnusp.libsql.hrana.codec.Row;
5+
import com.github.magnusp.libsql.hrana.codec.StmtResult;
6+
import com.github.magnusp.libsql.hrana.codec.Value;
7+
import org.junit.jupiter.api.Test;
8+
import org.junit.jupiter.params.ParameterizedTest;
9+
import org.junit.jupiter.params.provider.Arguments;
10+
import org.junit.jupiter.params.provider.MethodSource;
11+
12+
import java.nio.charset.StandardCharsets;
13+
import java.sql.SQLException;
14+
import java.sql.SQLFeatureNotSupportedException;
15+
import java.util.List;
16+
import java.util.UUID;
17+
import java.util.stream.Stream;
18+
19+
import static org.assertj.core.api.Assertions.assertThat;
20+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
21+
22+
class LibsqlResultSetGetObjectTest {
23+
24+
static Stream<Arguments> numericTargets() {
25+
return Stream.of(
26+
Arguments.of(Integer.class, "Integer", 7, 7),
27+
Arguments.of(Long.class, "Long", 7L, 7L),
28+
Arguments.of(Float.class, "Float", 7.0F, 7.5F),
29+
Arguments.of(Double.class, "Double", 7.0, 7.5));
30+
}
31+
32+
static Stream<Arguments> nonIntegerOrFloatStorage() {
33+
return Stream.of(
34+
Arguments.of("numeric text", new Value.Text("7")),
35+
Arguments.of("non-numeric text", new Value.Text("abc")),
36+
Arguments.of("numeric blob", new Value.Blob("123".getBytes(StandardCharsets.UTF_8))),
37+
Arguments.of("non-numeric blob", new Value.Blob("abc".getBytes(StandardCharsets.UTF_8))),
38+
Arguments.of("null", new Value.Null()));
39+
}
40+
41+
@ParameterizedTest(name = "{1} from INTEGER storage")
42+
@MethodSource("numericTargets")
43+
void numericTargetsReadIntegerStorage(
44+
Class<?> target, String typeName, Object fromInteger, Object fromFloat) throws SQLException {
45+
LibsqlResultSet rs = resultSet(new Value.Integer(7));
46+
assertThat(rs.next()).isTrue();
47+
48+
assertThat(rs.getObject(1, target)).isEqualTo(fromInteger);
49+
}
50+
51+
@ParameterizedTest(name = "{1} from FLOAT storage")
52+
@MethodSource("numericTargets")
53+
void numericTargetsReadFloatStorage(
54+
Class<?> target, String typeName, Object fromInteger, Object fromFloat) throws SQLException {
55+
LibsqlResultSet rs = resultSet(new Value.Float(7.5));
56+
assertThat(rs.next()).isTrue();
57+
58+
assertThat(rs.getObject(1, target)).isEqualTo(fromFloat);
59+
}
60+
61+
@ParameterizedTest(name = "{1} rejects {0}")
62+
@MethodSource("numericTargets")
63+
void numericTargetsRejectTextBlobAndNullStorage(Class<?> target, String typeName)
64+
throws SQLException {
65+
for (Arguments arguments : nonIntegerOrFloatStorage().toList()) {
66+
Object[] parts = arguments.get();
67+
Value value = (Value) parts[1];
68+
69+
LibsqlResultSet rs = resultSet(value);
70+
assertThat(rs.next()).isTrue();
71+
72+
assertThatThrownBy(() -> rs.getObject(1, target))
73+
.as("%s from %s storage", typeName, parts[0])
74+
.isExactlyInstanceOf(SQLException.class)
75+
.hasMessage("Bad value for type " + typeName);
76+
}
77+
}
78+
79+
static Stream<Arguments> shortAndByteTargets() {
80+
return Stream.of(Arguments.of(Short.class, "Short"), Arguments.of(Byte.class, "Byte"));
81+
}
82+
83+
@ParameterizedTest(name = "{1} is unsupported")
84+
@MethodSource("shortAndByteTargets")
85+
void shortAndByteAreUnsupportedForEveryStorageClass(Class<?> target, String typeName)
86+
throws SQLException {
87+
for (Arguments arguments : nonIntegerOrFloatStorage().toList()) {
88+
Object[] parts = arguments.get();
89+
Value value = (Value) parts[1];
90+
91+
LibsqlResultSet rs = resultSet(value);
92+
assertThat(rs.next()).isTrue();
93+
94+
assertThatThrownBy(() -> rs.getObject(1, target))
95+
.as("%s from %s storage", typeName, parts[0])
96+
.isExactlyInstanceOf(SQLFeatureNotSupportedException.class)
97+
.hasMessage("not implemented by SQLite JDBC driver");
98+
}
99+
100+
for (Value value : List.of(new Value.Integer(7), new Value.Float(7.5))) {
101+
LibsqlResultSet rs = resultSet(value);
102+
assertThat(rs.next()).isTrue();
103+
104+
assertThatThrownBy(() -> rs.getObject(1, target))
105+
.as("%s from %s storage", typeName, value.getClass().getSimpleName())
106+
.isExactlyInstanceOf(SQLFeatureNotSupportedException.class)
107+
.hasMessage("not implemented by SQLite JDBC driver");
108+
}
109+
}
110+
111+
static Stream<Arguments> unsupportedTargets() {
112+
return Stream.of(
113+
Arguments.of(Object.class),
114+
Arguments.of(Number.class),
115+
Arguments.of(UUID.class));
116+
}
117+
118+
@ParameterizedTest(name = "{0} is unsupported")
119+
@MethodSource("unsupportedTargets")
120+
void unsupportedTargetTypeUsesXerialMessage(Class<?> target) throws SQLException {
121+
LibsqlResultSet rs = resultSet(new Value.Integer(7));
122+
assertThat(rs.next()).isTrue();
123+
124+
assertThatThrownBy(() -> rs.getObject(1, target))
125+
.isExactlyInstanceOf(SQLFeatureNotSupportedException.class)
126+
.hasMessage("not implemented by SQLite JDBC driver");
127+
}
128+
129+
@Test
130+
void nullTargetTypeUsesXerialMessage() throws SQLException {
131+
LibsqlResultSet rs = resultSet(new Value.Integer(7));
132+
assertThat(rs.next()).isTrue();
133+
134+
assertThatThrownBy(() -> rs.getObject(1, (Class<Object>) null))
135+
.isExactlyInstanceOf(SQLException.class)
136+
.hasMessage("requested type cannot be null");
137+
}
138+
139+
@Test
140+
void nonNumericTargetsKeepTheirUnrestrictedConversions() throws SQLException {
141+
LibsqlResultSet integers = resultSet(new Value.Integer(7));
142+
assertThat(integers.next()).isTrue();
143+
assertThat(integers.getObject(1, String.class)).isEqualTo("7");
144+
assertThat(integers.getObject(1, byte[].class))
145+
.isEqualTo("7".getBytes(StandardCharsets.UTF_8));
146+
147+
LibsqlResultSet text = resultSet(new Value.Text("abc"));
148+
assertThat(text.next()).isTrue();
149+
assertThat(text.getObject(1, String.class)).isEqualTo("abc");
150+
assertThat(text.getObject(1, Boolean.class)).isFalse();
151+
152+
LibsqlResultSet nullValue = resultSet(new Value.Null());
153+
assertThat(nullValue.next()).isTrue();
154+
assertThat(nullValue.getObject(1, String.class)).isNull();
155+
assertThat(nullValue.getObject(1, Boolean.class)).isFalse();
156+
assertThat(nullValue.getObject(1, byte[].class)).isNull();
157+
}
158+
159+
private static LibsqlResultSet resultSet(Value value) {
160+
List<Col> cols = List.of(new Col("c", null));
161+
StmtResult result = new StmtResult(cols, List.of(new Row(List.of(value))), 1, null);
162+
return new LibsqlResultSet(null, result);
163+
}
164+
}

0 commit comments

Comments
 (0)