From fb993bd06de73bef417193faa803949cd24fe8e8 Mon Sep 17 00:00:00 2001 From: Magnus Persson Date: Fri, 23 Oct 2026 01:09:18 +0200 Subject: [PATCH] fix(jdbc): read date and time getters by SQLite storage class INTEGER is epoch milliseconds, FLOAT is a Julian day, and TEXT uses SQLite date and time formats. All java.sql and java.time targets now share the same storage-class conversion, and the #69 conversion table documents the SQLite conventions. --- .../magnusp/libsql/jdbc/LibsqlResultSet.java | 194 ++++++++++++------ .../libsql/jdbc/LibsqlResultSetTest.java | 47 +++++ 2 files changed, 178 insertions(+), 63 deletions(-) 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 44b3247..9323016 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 @@ -26,10 +26,13 @@ import java.sql.Statement; import java.sql.Time; import java.sql.Timestamp; +import java.time.DateTimeException; +import java.time.Instant; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; -import java.time.format.DateTimeParseException; +import java.time.ZoneId; +import java.time.ZoneOffset; import java.util.Calendar; import java.util.HashMap; import java.util.List; @@ -632,43 +635,20 @@ public byte[] getBytes(int columnIndex) throws SQLException { @Override public Date getDate(int columnIndex) throws SQLException { - String s = getString(columnIndex); - if (s == null) return null; - try { - return Date.valueOf(s); - } catch (IllegalArgumentException e) { - try { - return new Date(Long.parseLong(s)); - } catch (NumberFormatException nfe) { - return null; - } - } + LocalDateTime value = dateTimeValue(columnIndex, "Date"); + return value == null ? null : Date.valueOf(value.toLocalDate()); } @Override public Time getTime(int columnIndex) throws SQLException { - String s = getString(columnIndex); - if (s == null) return null; - try { - return Time.valueOf(s); - } catch (IllegalArgumentException e) { - return null; - } + LocalDateTime value = dateTimeValue(columnIndex, "Time"); + return value == null ? null : Time.valueOf(value.toLocalTime()); } @Override public Timestamp getTimestamp(int columnIndex) throws SQLException { - String s = getString(columnIndex); - if (s == null) return null; - try { - return Timestamp.valueOf(s); - } catch (IllegalArgumentException e) { - try { - return new Timestamp(Long.parseLong(s)); - } catch (NumberFormatException nfe) { - return null; - } - } + LocalDateTime value = dateTimeValue(columnIndex, "Timestamp"); + return value == null ? null : Timestamp.valueOf(value); } @Override @@ -1001,9 +981,11 @@ public void setFetchSize(int rows) throws SQLException { /** * 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): + *

The numeric and text conversions match sqlite-jdbc 3.53.4.0. Date and time conversions + * follow the SQLite storage-class conventions instead of parsing the value's text form. + * 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): * * * @@ -1037,7 +1019,7 @@ public void setFetchSize(int rows) throws SQLException { * * - * + * * * * @@ -1046,11 +1028,19 @@ public void setFetchSize(int rows) throws SQLException { * *
Target type by source storage class
{@code Date}, {@code Time}, {@code Timestamp}, {@code LocalDate}, {@code LocalTime}, * {@code LocalDateTime}text parsetext parsetext parsetext parseepoch millisJulian daydate/time textbad value{@code null}
* + *

INTEGER date and time values are milliseconds since the Unix epoch, FLOAT values are + * Julian days, and TEXT values use the SQLite date and time formats, including a bare date and + * a bare time. A bare time uses SQLite's 2000-01-01 default date. The SQLite TEXT format is + * {@code yyyy-MM-dd HH:mm:ss.SSS}, with optional fractional digits and an optional time zone + * suffix. All six date and time targets are built from the same storage-class value, so + * {@code getObject(column, LocalDate.class)} and {@code getDate(column)} agree. + * *

{@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 + * any storage class other than INTEGER or FLOAT, a date or time target reads a BLOB or + * unparseable TEXT, or the value cannot be converted * @throws SQLFeatureNotSupportedException if {@code type} is not a supported target type */ @Override @@ -1120,46 +1110,124 @@ public T getObject(int columnIndex, Class type) throws SQLException { } private LocalDate getLocalDate(int columnIndex) throws SQLException { - String s = getString(columnIndex); - if (s == null) { - return null; - } + LocalDateTime value = dateTimeValue(columnIndex, "LocalDate"); + return value == null ? null : value.toLocalDate(); + } + + private LocalTime getLocalTime(int columnIndex) throws SQLException { + LocalDateTime value = dateTimeValue(columnIndex, "LocalTime"); + return value == null ? null : value.toLocalTime(); + } + + private LocalDateTime getLocalDateTime(int columnIndex) throws SQLException { + return dateTimeValue(columnIndex, "LocalDateTime"); + } + + /** + * Reads a column as a local date and time using SQLite storage-class conventions: INTEGER is + * epoch milliseconds, FLOAT is a Julian day, and TEXT is a SQLite date/time string. + */ + private LocalDateTime dateTimeValue(int columnIndex, String targetType) throws SQLException { + Value value = getCurrentValue(columnIndex); + return switch (value) { + case Value.Null n -> null; + case Value.Integer i -> epochMillisToLocalDateTime(i.value(), targetType); + case Value.Float f -> + epochMillisToLocalDateTime(julianDayToEpochMillis(f.value()), targetType); + case Value.Text t -> parseSqliteDateTime(t.value(), targetType); + case Value.Blob b -> throw new SQLException("Bad value for type " + targetType); + }; + } + + private static LocalDateTime epochMillisToLocalDateTime(long epochMillis, String targetType) + throws SQLException { try { - return LocalDate.parse(s.length() >= 10 ? s.substring(0, 10) : s); - } catch (DateTimeParseException e) { - throw new SQLException("Bad value for type LocalDate: " + s, e); + return LocalDateTime.ofInstant( + Instant.ofEpochMilli(epochMillis), ZoneId.systemDefault()); + } catch (DateTimeException e) { + throw new SQLException("Bad value for type " + targetType, e); } } - private LocalTime getLocalTime(int columnIndex) throws SQLException { - String s = getString(columnIndex); - if (s == null) { - return null; + private static long julianDayToEpochMillis(double julianDay) { + return Math.round((julianDay - 2440587.5) * 86_400_000.0); + } + + private static LocalDateTime parseSqliteDateTime(String text, String targetType) + throws SQLException { + String value = text.trim(); + ZoneOffset offset = null; + if (value.endsWith("Z") || value.endsWith("z")) { + offset = ZoneOffset.UTC; + value = value.substring(0, value.length() - 1); + } else if (value.length() > 10) { + int sign = Math.max(value.lastIndexOf('+'), value.lastIndexOf('-')); + if (sign > 9) { + try { + offset = ZoneOffset.of(value.substring(sign)); + } catch (DateTimeException e) { + throw new SQLException("Bad value for type " + targetType + ": " + text, e); + } + value = value.substring(0, sign); + } } - int separator = Math.max(s.indexOf(' '), s.indexOf('T')); - String value = separator >= 0 ? s.substring(separator + 1) : s; + try { - return LocalTime.parse(value); - } catch (DateTimeParseException e) { - throw new SQLException("Bad value for type LocalTime: " + s, e); + int separator = -1; + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if (c == ' ' || c == 'T') { + separator = i; + break; + } + } + + LocalDate date; + LocalTime time; + if (separator >= 0) { + date = LocalDate.parse(value.substring(0, separator)); + time = parseSqliteTime(value.substring(separator + 1)); + } else if (value.length() >= 10 && value.charAt(4) == '-') { + date = LocalDate.parse(value); + time = LocalTime.MIDNIGHT; + } else { + // SQLite uses 2000-01-01 when a time string carries no date. + date = LocalDate.of(2000, 1, 1); + time = parseSqliteTime(value); + } + + LocalDateTime local = LocalDateTime.of(date, time); + return offset == null + ? local + : local.atOffset(offset) + .atZoneSameInstant(ZoneId.systemDefault()) + .toLocalDateTime(); + } catch (DateTimeException | NumberFormatException e) { + throw new SQLException("Bad value for type " + targetType + ": " + text, e); } } - private LocalDateTime getLocalDateTime(int columnIndex) throws SQLException { - String s = getString(columnIndex); - if (s == null) { - return null; + private static LocalTime parseSqliteTime(String text) { + int dot = text.indexOf('.'); + String clock = dot >= 0 ? text.substring(0, dot) : text; + String[] parts = clock.split(":", -1); + if (parts.length < 2 || parts.length > 3) { + throw new DateTimeException("Bad time: " + text); } - String value = s.indexOf(' ') >= 0 ? s.replace(' ', 'T') : s; - try { - return LocalDateTime.parse(value); - } catch (DateTimeParseException e) { - try { - return LocalDate.parse(value).atStartOfDay(); - } catch (DateTimeParseException e2) { - throw new SQLException("Bad value for type LocalDateTime: " + s, e2); + int hour = Integer.parseInt(parts[0]); + int minute = Integer.parseInt(parts[1]); + int second = parts.length == 3 ? Integer.parseInt(parts[2]) : 0; + + int nanos = 0; + if (dot >= 0) { + String fraction = text.substring(dot + 1); + String digits = fraction.length() > 9 ? fraction.substring(0, 9) : fraction; + nanos = Integer.parseInt(digits); + for (int i = digits.length(); i < 9; i++) { + nanos *= 10; } } + return LocalTime.of(hour, minute, second, nanos); } @Override 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 1eeec5d..bcd870f 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 @@ -8,6 +8,11 @@ import java.nio.charset.StandardCharsets; import java.sql.SQLException; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.ZoneId; import java.util.List; import java.util.stream.IntStream; @@ -218,6 +223,48 @@ void getObjectNumericConversionsRejectNumericTextLikeXerial() throws SQLExceptio .hasMessage("Bad value for type Integer"); } + @Test + void textDateTimeFeedsEveryDateAndTimeGetter() throws SQLException { + LibsqlResultSet rs = resultSet(new Value.Text("2024-01-02 10:00:00")); + assertThat(rs.next()).isTrue(); + + long epochMillis = LocalDateTime.of(2024, 1, 2, 10, 0) + .atZone(ZoneId.systemDefault()) + .toInstant() + .toEpochMilli(); + assertDateTimeGetters(rs, epochMillis); + } + + @Test + void integerEpochMillisFeedsEveryDateAndTimeGetter() throws SQLException { + LibsqlResultSet rs = resultSet(new Value.Integer(1_700_000_000_000L)); + assertThat(rs.next()).isTrue(); + + assertDateTimeGetters(rs, 1_700_000_000_000L); + } + + @Test + void realJulianDayFeedsEveryDateAndTimeGetter() throws SQLException { + LibsqlResultSet rs = resultSet(new Value.Float(2460311.5)); + assertThat(rs.next()).isTrue(); + + assertDateTimeGetters(rs, 1_704_153_600_000L); + } + + private static void assertDateTimeGetters(LibsqlResultSet rs, long epochMillis) throws SQLException { + LocalDateTime expected = LocalDateTime.ofInstant(Instant.ofEpochMilli(epochMillis), ZoneId.systemDefault()); + + assertThat(rs.getDate(1)).isNotNull(); + assertThat(rs.getDate(1).toLocalDate()).isEqualTo(expected.toLocalDate()); + assertThat(rs.getTime(1)).isNotNull(); + assertThat(rs.getTime(1).toLocalTime()).isEqualTo(expected.toLocalTime()); + assertThat(rs.getTimestamp(1)).isNotNull(); + assertThat(rs.getTimestamp(1).getTime()).isEqualTo(epochMillis); + assertThat(rs.getObject(1, LocalDate.class)).isEqualTo(expected.toLocalDate()); + assertThat(rs.getObject(1, LocalTime.class)).isEqualTo(expected.toLocalTime()); + assertThat(rs.getObject(1, LocalDateTime.class)).isEqualTo(expected); + } + private static void assertNumeric(LibsqlResultSet rs, int column, int expectedInt, long expectedLong, float expectedFloat, double expectedDouble) throws SQLException { assertThat(rs.getInt(column)).isEqualTo(expectedInt);