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 16a35eb..6d58992 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 @@ -148,6 +148,58 @@ public short getShort(int columnIndex) throws SQLException { @Override public int getInt(int columnIndex) throws SQLException { Value val = getCurrentValue(columnIndex); + 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 -> (int) parseSqliteLong(t.value()); + case Value.Blob b -> (int) parseSqliteLong(blobAsText(b)); + }; + } + + @Override + public long getLong(int columnIndex) throws SQLException { + return toLong(getCurrentValue(columnIndex)); + } + + @Override + public float getFloat(int columnIndex) throws SQLException { + Value val = getCurrentValue(columnIndex); + 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 -> (float) parseSqliteDouble(t.value()); + case Value.Blob b -> (float) parseSqliteDouble(blobAsText(b)); + }; + } + + @Override + public double getDouble(int columnIndex) throws SQLException { + return toDouble(getCurrentValue(columnIndex)); + } + + private static long toLong(Value val) { + 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 -> parseSqliteLong(t.value()); + case Value.Blob b -> parseSqliteLong(blobAsText(b)); + }; + } + + private static double toDouble(Value val) { + 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 -> parseSqliteDouble(t.value()); + case Value.Blob b -> parseSqliteDouble(blobAsText(b)); + }; + } + + private static int toStrictInt(Value val) throws SQLException { return switch (val) { case Value.Null n -> 0; case Value.Integer i -> (int) i.value(); @@ -163,9 +215,7 @@ public int getInt(int columnIndex) throws SQLException { }; } - @Override - public long getLong(int columnIndex) throws SQLException { - Value val = getCurrentValue(columnIndex); + private static long toStrictLong(Value val) throws SQLException { return switch (val) { case Value.Null n -> 0L; case Value.Integer i -> i.value(); @@ -181,9 +231,7 @@ public long getLong(int columnIndex) throws SQLException { }; } - @Override - public float getFloat(int columnIndex) throws SQLException { - Value val = getCurrentValue(columnIndex); + private static float toStrictFloat(Value val) throws SQLException { return switch (val) { case Value.Null n -> 0.0F; case Value.Integer i -> (float) i.value(); @@ -199,9 +247,7 @@ public float getFloat(int columnIndex) throws SQLException { }; } - @Override - public double getDouble(int columnIndex) throws SQLException { - Value val = getCurrentValue(columnIndex); + private static double toStrictDouble(Value val) throws SQLException { return switch (val) { case Value.Null n -> 0.0; case Value.Integer i -> (double) i.value(); @@ -217,6 +263,111 @@ public double getDouble(int columnIndex) throws SQLException { }; } + private static String blobAsText(Value.Blob b) { + return new String(b.bytes(), java.nio.charset.StandardCharsets.UTF_8); + } + + private static long parseSqliteLong(String text) { + int length = text.length(); + int index = skipSqliteWhitespace(text, 0); + boolean negative = false; + if (index < length && (text.charAt(index) == '+' || text.charAt(index) == '-')) { + negative = text.charAt(index) == '-'; + index++; + } + + int digitsStart = index; + long value = 0L; + boolean overflow = false; + while (index < length) { + char c = text.charAt(index); + if (c < '0' || c > '9') { + break; + } + if (!overflow) { + int digit = c - '0'; + if (value > (Long.MAX_VALUE - digit) / 10) { + overflow = true; + } else { + value = value * 10 + digit; + } + } + index++; + } + + if (index == digitsStart) { + return 0L; + } + if (overflow) { + return negative ? Long.MIN_VALUE : Long.MAX_VALUE; + } + return negative ? -value : value; + } + + private static double parseSqliteDouble(String text) { + int length = text.length(); + int index = skipSqliteWhitespace(text, 0); + int start = index; + if (index < length && (text.charAt(index) == '+' || text.charAt(index) == '-')) { + index++; + } + + int digits = 0; + while (index < length && isAsciiDigit(text.charAt(index))) { + index++; + digits++; + } + if (index < length && text.charAt(index) == '.') { + index++; + while (index < length && isAsciiDigit(text.charAt(index))) { + index++; + digits++; + } + } + + if (digits == 0) { + return 0.0; + } + + int end = index; + if (index < length && (text.charAt(index) == 'e' || text.charAt(index) == 'E')) { + int exponentIndex = index + 1; + if (exponentIndex < length + && (text.charAt(exponentIndex) == '+' || text.charAt(exponentIndex) == '-')) { + exponentIndex++; + } + int exponentDigits = 0; + while (exponentIndex < length && isAsciiDigit(text.charAt(exponentIndex))) { + exponentIndex++; + exponentDigits++; + } + if (exponentDigits > 0) { + end = exponentIndex; + } + } + + try { + return Double.parseDouble(text.substring(start, end)); + } catch (NumberFormatException e) { + return 0.0; + } + } + + private static int skipSqliteWhitespace(String text, int index) { + while (index < text.length() && isSqliteWhitespace(text.charAt(index))) { + index++; + } + return index; + } + + private static boolean isSqliteWhitespace(char c) { + return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\u000b' || c == '\f'; + } + + private static boolean isAsciiDigit(char c) { + return c >= '0' && c <= '9'; + } + @Override @Deprecated @SuppressWarnings("deprecation") @@ -607,6 +758,7 @@ public T getObject(int columnIndex, Class type) throws SQLException { if (val == null) { return null; } + Value stored = getCurrentValue(columnIndex); if (type.isInstance(val)) { return type.cast(val); } @@ -623,22 +775,22 @@ public T getObject(int columnIndex, Class type) throws SQLException { return type.cast(getBytes(columnIndex)); } if (type == Double.class) { - return type.cast(getDouble(columnIndex)); + return type.cast(toStrictDouble(stored)); } if (type == Long.class) { - return type.cast(getLong(columnIndex)); + return type.cast(toStrictLong(stored)); } if (type == Float.class) { - return type.cast(getFloat(columnIndex)); + return type.cast(toStrictFloat(stored)); } if (type == Integer.class) { - return type.cast(getInt(columnIndex)); + return type.cast(toStrictInt(stored)); } if (type == Short.class) { - return type.cast(getShort(columnIndex)); + return type.cast(Short.valueOf((short) toStrictLong(stored))); } if (type == Byte.class) { - return type.cast(getByte(columnIndex)); + return type.cast(Byte.valueOf((byte) toStrictLong(stored))); } if (type == Date.class) { return type.cast(getDate(columnIndex)); 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 a6d26c6..f2b3a5a 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 @@ -6,10 +6,13 @@ import com.github.magnusp.libsql.hrana.codec.Value; import org.junit.jupiter.api.Test; +import java.nio.charset.StandardCharsets; import java.sql.SQLException; import java.util.List; +import java.util.stream.IntStream; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; class LibsqlResultSetTest { @@ -81,4 +84,121 @@ void statementCloseClosesCurrentResultSet() throws SQLException { assertThat(resultSet.isClosed()).isTrue(); } + + @Test + void nonNumericTextCoercesToZero() throws SQLException { + LibsqlResultSet rs = resultSet(new Value.Text("abc")); + assertThat(rs.next()).isTrue(); + assertNumeric(rs, 1, 0, 0L, 0.0f, 0.0); + } + + @Test + void numericTextCoercesToNumber() throws SQLException { + LibsqlResultSet rs = resultSet(new Value.Text("123")); + assertThat(rs.next()).isTrue(); + assertNumeric(rs, 1, 123, 123L, 123.0f, 123.0); + } + + @Test + void realTextTruncatesForIntegralGettersAndKeepsFractionForFloatingGetters() throws SQLException { + LibsqlResultSet rs = resultSet(new Value.Text("1.9")); + assertThat(rs.next()).isTrue(); + assertNumeric(rs, 1, 1, 1L, 1.9f, 1.9); + } + + @Test + void textWithExponentUsesIntegerPrefixForIntegralGetters() throws SQLException { + LibsqlResultSet rs = resultSet(new Value.Text("1.5e2")); + assertThat(rs.next()).isTrue(); + assertNumeric(rs, 1, 1, 1L, 150.0f, 150.0); + } + + @Test + void textWithLeadingWhitespaceAndTrailingGarbageUsesNumericPrefix() throws SQLException { + LibsqlResultSet rs = resultSet(new Value.Text(" 42xyz")); + assertThat(rs.next()).isTrue(); + assertNumeric(rs, 1, 42, 42L, 42.0f, 42.0); + } + + @Test + void numericBlobIsInterpretedAsText() throws SQLException { + LibsqlResultSet rs = resultSet(new Value.Blob("123".getBytes(StandardCharsets.UTF_8))); + assertThat(rs.next()).isTrue(); + assertNumeric(rs, 1, 123, 123L, 123.0f, 123.0); + } + + @Test + void realTextBlobIsInterpretedAsText() throws SQLException { + LibsqlResultSet rs = resultSet(new Value.Blob("1.9".getBytes(StandardCharsets.UTF_8))); + assertThat(rs.next()).isTrue(); + assertNumeric(rs, 1, 1, 1L, 1.9f, 1.9); + } + + @Test + void nonNumericBlobCoercesToZero() throws SQLException { + LibsqlResultSet rs = resultSet(new Value.Blob("abc".getBytes(StandardCharsets.UTF_8))); + assertThat(rs.next()).isTrue(); + assertNumeric(rs, 1, 0, 0L, 0.0f, 0.0); + } + + @Test + void realStorageTruncatesForIntegralGettersAndKeepsFractionForFloatingGetters() throws SQLException { + LibsqlResultSet rs = resultSet(new Value.Float(1.9)); + assertThat(rs.next()).isTrue(); + assertNumeric(rs, 1, 1, 1L, 1.9f, 1.9); + } + + @Test + void integerStorageIsReturnedUnchanged() throws SQLException { + LibsqlResultSet rs = resultSet(new Value.Integer(42)); + assertThat(rs.next()).isTrue(); + assertNumeric(rs, 1, 42, 42L, 42.0f, 42.0); + } + + @Test + void getObjectNumericConversionsStayStrictForNonNumericText() throws SQLException { + LibsqlResultSet rs = resultSet(new Value.Text("abc")); + assertThat(rs.next()).isTrue(); + + assertNumeric(rs, 1, 0, 0L, 0.0f, 0.0); + assertThatThrownBy(() -> rs.getObject(1, Double.class)) + .isInstanceOf(SQLException.class) + .hasMessageContaining("Bad value for type Double"); + assertThatThrownBy(() -> rs.getObject(1, Long.class)) + .isInstanceOf(SQLException.class) + .hasMessageContaining("Bad value for type Long"); + assertThatThrownBy(() -> rs.getObject(1, Float.class)) + .isInstanceOf(SQLException.class) + .hasMessageContaining("Bad value for type Float"); + assertThatThrownBy(() -> rs.getObject(1, Integer.class)) + .isInstanceOf(SQLException.class) + .hasMessageContaining("Bad value for type Integer"); + } + + @Test + void getObjectNumericConversionsAcceptNumericText() 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); + } + + private static void assertNumeric(LibsqlResultSet rs, int column, int expectedInt, long expectedLong, + float expectedFloat, double expectedDouble) throws SQLException { + assertThat(rs.getInt(column)).isEqualTo(expectedInt); + assertThat(rs.getLong(column)).isEqualTo(expectedLong); + assertThat(rs.getFloat(column)).isEqualTo(expectedFloat); + assertThat(rs.getDouble(column)).isEqualTo(expectedDouble); + } + + private static LibsqlResultSet resultSet(Value... values) { + List cols = IntStream.range(0, values.length) + .mapToObj(i -> new Col("c" + (i + 1), null)) + .toList(); + StmtResult result = new StmtResult(cols, List.of(new Row(List.of(values))), 1, null); + return new LibsqlResultSet(null, result); + } }