Skip to content

Commit fb993bd

Browse files
author
Magnus Persson
committed
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.
1 parent 168f69a commit fb993bd

2 files changed

Lines changed: 178 additions & 63 deletions

File tree

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

Lines changed: 131 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,13 @@
2626
import java.sql.Statement;
2727
import java.sql.Time;
2828
import java.sql.Timestamp;
29+
import java.time.DateTimeException;
30+
import java.time.Instant;
2931
import java.time.LocalDate;
3032
import java.time.LocalDateTime;
3133
import java.time.LocalTime;
32-
import java.time.format.DateTimeParseException;
34+
import java.time.ZoneId;
35+
import java.time.ZoneOffset;
3336
import java.util.Calendar;
3437
import java.util.HashMap;
3538
import java.util.List;
@@ -632,43 +635,20 @@ public byte[] getBytes(int columnIndex) throws SQLException {
632635

633636
@Override
634637
public Date getDate(int columnIndex) throws SQLException {
635-
String s = getString(columnIndex);
636-
if (s == null) return null;
637-
try {
638-
return Date.valueOf(s);
639-
} catch (IllegalArgumentException e) {
640-
try {
641-
return new Date(Long.parseLong(s));
642-
} catch (NumberFormatException nfe) {
643-
return null;
644-
}
645-
}
638+
LocalDateTime value = dateTimeValue(columnIndex, "Date");
639+
return value == null ? null : Date.valueOf(value.toLocalDate());
646640
}
647641

648642
@Override
649643
public Time getTime(int columnIndex) throws SQLException {
650-
String s = getString(columnIndex);
651-
if (s == null) return null;
652-
try {
653-
return Time.valueOf(s);
654-
} catch (IllegalArgumentException e) {
655-
return null;
656-
}
644+
LocalDateTime value = dateTimeValue(columnIndex, "Time");
645+
return value == null ? null : Time.valueOf(value.toLocalTime());
657646
}
658647

659648
@Override
660649
public Timestamp getTimestamp(int columnIndex) throws SQLException {
661-
String s = getString(columnIndex);
662-
if (s == null) return null;
663-
try {
664-
return Timestamp.valueOf(s);
665-
} catch (IllegalArgumentException e) {
666-
try {
667-
return new Timestamp(Long.parseLong(s));
668-
} catch (NumberFormatException nfe) {
669-
return null;
670-
}
671-
}
650+
LocalDateTime value = dateTimeValue(columnIndex, "Timestamp");
651+
return value == null ? null : Timestamp.valueOf(value);
672652
}
673653

674654
@Override
@@ -1001,9 +981,11 @@ public void setFetchSize(int rows) throws SQLException {
1001981
/**
1002982
* Returns a column value converted to the requested Java type.
1003983
*
1004-
* <p>The conversion matrix matches sqlite-jdbc 3.53.4.0. Numeric wrapper targets are gated on
1005-
* the current value's SQLite storage class; any other storage class raises {@code Bad value
1006-
* for type Integer} (or the corresponding target type name):
984+
* <p>The numeric and text conversions match sqlite-jdbc 3.53.4.0. Date and time conversions
985+
* follow the SQLite storage-class conventions instead of parsing the value's text form.
986+
* Numeric wrapper targets are gated on the current value's SQLite storage class; any other
987+
* storage class raises {@code Bad value for type Integer} (or the corresponding target type
988+
* name):
1007989
*
1008990
* <table>
1009991
* <caption>Target type by source storage class</caption>
@@ -1037,7 +1019,7 @@ public void setFetchSize(int rows) throws SQLException {
10371019
* <tr>
10381020
* <td>{@code Date}, {@code Time}, {@code Timestamp}, {@code LocalDate}, {@code LocalTime},
10391021
* {@code LocalDateTime}</td>
1040-
* <td>text parse</td><td>text parse</td><td>text parse</td><td>text parse</td>
1022+
* <td>epoch millis</td><td>Julian day</td><td>date/time text</td><td>bad value</td>
10411023
* <td>{@code null}</td>
10421024
* </tr>
10431025
* <tr>
@@ -1046,11 +1028,19 @@ public void setFetchSize(int rows) throws SQLException {
10461028
* </tr>
10471029
* </table>
10481030
*
1031+
* <p>INTEGER date and time values are milliseconds since the Unix epoch, FLOAT values are
1032+
* Julian days, and TEXT values use the SQLite date and time formats, including a bare date and
1033+
* a bare time. A bare time uses SQLite's 2000-01-01 default date. The SQLite TEXT format is
1034+
* {@code yyyy-MM-dd HH:mm:ss.SSS}, with optional fractional digits and an optional time zone
1035+
* suffix. All six date and time targets are built from the same storage-class value, so
1036+
* {@code getObject(column, LocalDate.class)} and {@code getDate(column)} agree.
1037+
*
10491038
* <p>{@code Short} and {@code Byte} are rejected rather than narrowed from the numeric
10501039
* targets, preserving the sqlite-jdbc 3.53.4.0 contract.
10511040
*
10521041
* @throws SQLException if {@code type} is {@code null}, a numeric target reads a value from
1053-
* any storage class other than INTEGER or FLOAT, or the value cannot be converted
1042+
* any storage class other than INTEGER or FLOAT, a date or time target reads a BLOB or
1043+
* unparseable TEXT, or the value cannot be converted
10541044
* @throws SQLFeatureNotSupportedException if {@code type} is not a supported target type
10551045
*/
10561046
@Override
@@ -1120,46 +1110,124 @@ public <T> T getObject(int columnIndex, Class<T> type) throws SQLException {
11201110
}
11211111

11221112
private LocalDate getLocalDate(int columnIndex) throws SQLException {
1123-
String s = getString(columnIndex);
1124-
if (s == null) {
1125-
return null;
1126-
}
1113+
LocalDateTime value = dateTimeValue(columnIndex, "LocalDate");
1114+
return value == null ? null : value.toLocalDate();
1115+
}
1116+
1117+
private LocalTime getLocalTime(int columnIndex) throws SQLException {
1118+
LocalDateTime value = dateTimeValue(columnIndex, "LocalTime");
1119+
return value == null ? null : value.toLocalTime();
1120+
}
1121+
1122+
private LocalDateTime getLocalDateTime(int columnIndex) throws SQLException {
1123+
return dateTimeValue(columnIndex, "LocalDateTime");
1124+
}
1125+
1126+
/**
1127+
* Reads a column as a local date and time using SQLite storage-class conventions: INTEGER is
1128+
* epoch milliseconds, FLOAT is a Julian day, and TEXT is a SQLite date/time string.
1129+
*/
1130+
private LocalDateTime dateTimeValue(int columnIndex, String targetType) throws SQLException {
1131+
Value value = getCurrentValue(columnIndex);
1132+
return switch (value) {
1133+
case Value.Null n -> null;
1134+
case Value.Integer i -> epochMillisToLocalDateTime(i.value(), targetType);
1135+
case Value.Float f ->
1136+
epochMillisToLocalDateTime(julianDayToEpochMillis(f.value()), targetType);
1137+
case Value.Text t -> parseSqliteDateTime(t.value(), targetType);
1138+
case Value.Blob b -> throw new SQLException("Bad value for type " + targetType);
1139+
};
1140+
}
1141+
1142+
private static LocalDateTime epochMillisToLocalDateTime(long epochMillis, String targetType)
1143+
throws SQLException {
11271144
try {
1128-
return LocalDate.parse(s.length() >= 10 ? s.substring(0, 10) : s);
1129-
} catch (DateTimeParseException e) {
1130-
throw new SQLException("Bad value for type LocalDate: " + s, e);
1145+
return LocalDateTime.ofInstant(
1146+
Instant.ofEpochMilli(epochMillis), ZoneId.systemDefault());
1147+
} catch (DateTimeException e) {
1148+
throw new SQLException("Bad value for type " + targetType, e);
11311149
}
11321150
}
11331151

1134-
private LocalTime getLocalTime(int columnIndex) throws SQLException {
1135-
String s = getString(columnIndex);
1136-
if (s == null) {
1137-
return null;
1152+
private static long julianDayToEpochMillis(double julianDay) {
1153+
return Math.round((julianDay - 2440587.5) * 86_400_000.0);
1154+
}
1155+
1156+
private static LocalDateTime parseSqliteDateTime(String text, String targetType)
1157+
throws SQLException {
1158+
String value = text.trim();
1159+
ZoneOffset offset = null;
1160+
if (value.endsWith("Z") || value.endsWith("z")) {
1161+
offset = ZoneOffset.UTC;
1162+
value = value.substring(0, value.length() - 1);
1163+
} else if (value.length() > 10) {
1164+
int sign = Math.max(value.lastIndexOf('+'), value.lastIndexOf('-'));
1165+
if (sign > 9) {
1166+
try {
1167+
offset = ZoneOffset.of(value.substring(sign));
1168+
} catch (DateTimeException e) {
1169+
throw new SQLException("Bad value for type " + targetType + ": " + text, e);
1170+
}
1171+
value = value.substring(0, sign);
1172+
}
11381173
}
1139-
int separator = Math.max(s.indexOf(' '), s.indexOf('T'));
1140-
String value = separator >= 0 ? s.substring(separator + 1) : s;
1174+
11411175
try {
1142-
return LocalTime.parse(value);
1143-
} catch (DateTimeParseException e) {
1144-
throw new SQLException("Bad value for type LocalTime: " + s, e);
1176+
int separator = -1;
1177+
for (int i = 0; i < value.length(); i++) {
1178+
char c = value.charAt(i);
1179+
if (c == ' ' || c == 'T') {
1180+
separator = i;
1181+
break;
1182+
}
1183+
}
1184+
1185+
LocalDate date;
1186+
LocalTime time;
1187+
if (separator >= 0) {
1188+
date = LocalDate.parse(value.substring(0, separator));
1189+
time = parseSqliteTime(value.substring(separator + 1));
1190+
} else if (value.length() >= 10 && value.charAt(4) == '-') {
1191+
date = LocalDate.parse(value);
1192+
time = LocalTime.MIDNIGHT;
1193+
} else {
1194+
// SQLite uses 2000-01-01 when a time string carries no date.
1195+
date = LocalDate.of(2000, 1, 1);
1196+
time = parseSqliteTime(value);
1197+
}
1198+
1199+
LocalDateTime local = LocalDateTime.of(date, time);
1200+
return offset == null
1201+
? local
1202+
: local.atOffset(offset)
1203+
.atZoneSameInstant(ZoneId.systemDefault())
1204+
.toLocalDateTime();
1205+
} catch (DateTimeException | NumberFormatException e) {
1206+
throw new SQLException("Bad value for type " + targetType + ": " + text, e);
11451207
}
11461208
}
11471209

1148-
private LocalDateTime getLocalDateTime(int columnIndex) throws SQLException {
1149-
String s = getString(columnIndex);
1150-
if (s == null) {
1151-
return null;
1210+
private static LocalTime parseSqliteTime(String text) {
1211+
int dot = text.indexOf('.');
1212+
String clock = dot >= 0 ? text.substring(0, dot) : text;
1213+
String[] parts = clock.split(":", -1);
1214+
if (parts.length < 2 || parts.length > 3) {
1215+
throw new DateTimeException("Bad time: " + text);
11521216
}
1153-
String value = s.indexOf(' ') >= 0 ? s.replace(' ', 'T') : s;
1154-
try {
1155-
return LocalDateTime.parse(value);
1156-
} catch (DateTimeParseException e) {
1157-
try {
1158-
return LocalDate.parse(value).atStartOfDay();
1159-
} catch (DateTimeParseException e2) {
1160-
throw new SQLException("Bad value for type LocalDateTime: " + s, e2);
1217+
int hour = Integer.parseInt(parts[0]);
1218+
int minute = Integer.parseInt(parts[1]);
1219+
int second = parts.length == 3 ? Integer.parseInt(parts[2]) : 0;
1220+
1221+
int nanos = 0;
1222+
if (dot >= 0) {
1223+
String fraction = text.substring(dot + 1);
1224+
String digits = fraction.length() > 9 ? fraction.substring(0, 9) : fraction;
1225+
nanos = Integer.parseInt(digits);
1226+
for (int i = digits.length(); i < 9; i++) {
1227+
nanos *= 10;
11611228
}
11621229
}
1230+
return LocalTime.of(hour, minute, second, nanos);
11631231
}
11641232

11651233
@Override

‎libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlResultSetTest.java‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@
88

99
import java.nio.charset.StandardCharsets;
1010
import java.sql.SQLException;
11+
import java.time.Instant;
12+
import java.time.LocalDate;
13+
import java.time.LocalDateTime;
14+
import java.time.LocalTime;
15+
import java.time.ZoneId;
1116
import java.util.List;
1217
import java.util.stream.IntStream;
1318

@@ -218,6 +223,48 @@ void getObjectNumericConversionsRejectNumericTextLikeXerial() throws SQLExceptio
218223
.hasMessage("Bad value for type Integer");
219224
}
220225

226+
@Test
227+
void textDateTimeFeedsEveryDateAndTimeGetter() throws SQLException {
228+
LibsqlResultSet rs = resultSet(new Value.Text("2024-01-02 10:00:00"));
229+
assertThat(rs.next()).isTrue();
230+
231+
long epochMillis = LocalDateTime.of(2024, 1, 2, 10, 0)
232+
.atZone(ZoneId.systemDefault())
233+
.toInstant()
234+
.toEpochMilli();
235+
assertDateTimeGetters(rs, epochMillis);
236+
}
237+
238+
@Test
239+
void integerEpochMillisFeedsEveryDateAndTimeGetter() throws SQLException {
240+
LibsqlResultSet rs = resultSet(new Value.Integer(1_700_000_000_000L));
241+
assertThat(rs.next()).isTrue();
242+
243+
assertDateTimeGetters(rs, 1_700_000_000_000L);
244+
}
245+
246+
@Test
247+
void realJulianDayFeedsEveryDateAndTimeGetter() throws SQLException {
248+
LibsqlResultSet rs = resultSet(new Value.Float(2460311.5));
249+
assertThat(rs.next()).isTrue();
250+
251+
assertDateTimeGetters(rs, 1_704_153_600_000L);
252+
}
253+
254+
private static void assertDateTimeGetters(LibsqlResultSet rs, long epochMillis) throws SQLException {
255+
LocalDateTime expected = LocalDateTime.ofInstant(Instant.ofEpochMilli(epochMillis), ZoneId.systemDefault());
256+
257+
assertThat(rs.getDate(1)).isNotNull();
258+
assertThat(rs.getDate(1).toLocalDate()).isEqualTo(expected.toLocalDate());
259+
assertThat(rs.getTime(1)).isNotNull();
260+
assertThat(rs.getTime(1).toLocalTime()).isEqualTo(expected.toLocalTime());
261+
assertThat(rs.getTimestamp(1)).isNotNull();
262+
assertThat(rs.getTimestamp(1).getTime()).isEqualTo(epochMillis);
263+
assertThat(rs.getObject(1, LocalDate.class)).isEqualTo(expected.toLocalDate());
264+
assertThat(rs.getObject(1, LocalTime.class)).isEqualTo(expected.toLocalTime());
265+
assertThat(rs.getObject(1, LocalDateTime.class)).isEqualTo(expected);
266+
}
267+
221268
private static void assertNumeric(LibsqlResultSet rs, int column, int expectedInt, long expectedLong,
222269
float expectedFloat, double expectedDouble) throws SQLException {
223270
assertThat(rs.getInt(column)).isEqualTo(expectedInt);

0 commit comments

Comments
 (0)