diff --git a/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/LibsqlPreparedStatement.java b/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/LibsqlPreparedStatement.java index 542a096..4572885 100644 --- a/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/LibsqlPreparedStatement.java +++ b/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/LibsqlPreparedStatement.java @@ -27,9 +27,16 @@ import java.sql.SQLXML; import java.sql.Time; import java.sql.Timestamp; +import java.time.Instant; +import java.time.LocalTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.Calendar; import java.util.List; +import java.util.Locale; /** * Prepared statement over the Hrana transport. @@ -45,6 +52,9 @@ */ public class LibsqlPreparedStatement extends LibsqlStatement implements PreparedStatement, ParameterMetaData { + private static final DateTimeFormatter DATE_TEXT = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + private static final DateTimeFormatter TIME_TEXT = DateTimeFormatter.ofPattern("HH:mm:ss"); + private final String sql; private final boolean returnsResultSet; private final int parameterCount; @@ -324,17 +334,55 @@ public ResultSetMetaData getMetaData() throws SQLException { @Override public void setDate(int parameterIndex, Date x, Calendar cal) throws SQLException { - setDate(parameterIndex, x); + if (cal == null || x == null) { + setDate(parameterIndex, x); + return; + } + setParam(parameterIndex, Value.of(DATE_TEXT.format(Instant.ofEpochMilli(x.getTime()).atZone(zone(cal))))); } @Override public void setTime(int parameterIndex, Time x, Calendar cal) throws SQLException { - setTime(parameterIndex, x); + if (cal == null || x == null) { + setTime(parameterIndex, x); + return; + } + LocalTime time = Instant.ofEpochMilli(x.getTime()) + .atZone(zone(cal)) + .toLocalTime() + .truncatedTo(ChronoUnit.SECONDS); + setParam(parameterIndex, Value.of(TIME_TEXT.format(time))); } @Override public void setTimestamp(int parameterIndex, Timestamp x, Calendar cal) throws SQLException { - setTimestamp(parameterIndex, x); + if (cal == null || x == null) { + setTimestamp(parameterIndex, x); + return; + } + setParam(parameterIndex, Value.of(timestampText(x.toInstant().atZone(zone(cal))))); + } + + private static ZoneId zone(Calendar cal) { + return cal.getTimeZone().toZoneId(); + } + + /** + * The {@link Timestamp#toString()} text form: {@code yyyy-MM-dd HH:mm:ss}, a fraction with + * trailing zeros removed when nanos are non-zero, and {@code .0} when nanos are zero. + */ + private static String timestampText(ZonedDateTime value) { + String dateTime = DATE_TEXT.format(value) + " " + TIME_TEXT.format(value); + int nanos = value.getNano(); + if (nanos == 0) { + return dateTime + ".0"; + } + String fraction = String.format(Locale.ROOT, "%09d", nanos); + int end = fraction.length(); + while (end > 1 && fraction.charAt(end - 1) == '0') { + end--; + } + return dateTime + "." + fraction.substring(0, end); } @Override diff --git a/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/LibsqlResultSet.java b/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/LibsqlResultSet.java index 67edf47..d7d15d4 100644 --- a/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/LibsqlResultSet.java +++ b/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/LibsqlResultSet.java @@ -34,6 +34,7 @@ import java.time.LocalTime; import java.time.OffsetDateTime; import java.time.OffsetTime; +import java.time.ZoneId; import java.time.format.DateTimeParseException; import java.util.Calendar; import java.util.HashMap; @@ -389,6 +390,14 @@ private static Date dateValue(Value value) throws SQLException { return dateTime == null ? null : Date.valueOf(dateTime.toLocalDate()); } + private static Date dateValue(Value value, ZoneId zone) throws SQLException { + LocalDateTime dateTime = dateTimeValue(value, "Date", zone); + if (dateTime == null) { + return null; + } + return new Date(dateTime.toLocalDate().atStartOfDay(zone).toInstant().toEpochMilli()); + } + @Override public Time getTime(int columnIndex) throws SQLException { checkReadable(columnIndex); @@ -400,6 +409,18 @@ private static Time timeValue(Value value) throws SQLException { return dateTime == null ? null : Time.valueOf(dateTime.toLocalTime()); } + private static Time timeValue(Value value, ZoneId zone) throws SQLException { + LocalDateTime dateTime = dateTimeValue(value, "Time", zone); + if (dateTime == null) { + return null; + } + return new Time(LocalDate.of(1970, 1, 1) + .atTime(dateTime.toLocalTime()) + .atZone(zone) + .toInstant() + .toEpochMilli()); + } + @Override public Timestamp getTimestamp(int columnIndex) throws SQLException { checkReadable(columnIndex); @@ -411,6 +432,11 @@ private static Timestamp timestampValue(Value value) throws SQLException { return dateTime == null ? null : Timestamp.valueOf(dateTime); } + private static Timestamp timestampValue(Value value, ZoneId zone) throws SQLException { + LocalDateTime dateTime = dateTimeValue(value, "Timestamp", zone); + return dateTime == null ? null : Timestamp.from(dateTime.atZone(zone).toInstant()); + } + private static OffsetTime offsetTimeValue(Value value) throws SQLException { String text = stringValue(value); if (text == null) { @@ -893,12 +919,42 @@ private static Clob clobValue(Value value) { @Override public Blob getBlob(String columnLabel) throws SQLException { return getBlob(findColumn(columnLabel)); } @Override public Clob getClob(String columnLabel) throws SQLException { return getClob(findColumn(columnLabel)); } @Override public Array getArray(String columnLabel) throws SQLException { return getArray(findColumn(columnLabel)); } - @Override public Date getDate(int columnIndex, Calendar cal) throws SQLException { return getDate(columnIndex); } - @Override public Date getDate(String columnLabel, Calendar cal) throws SQLException { return getDate(columnLabel); } - @Override public Time getTime(int columnIndex, Calendar cal) throws SQLException { return getTime(columnIndex); } - @Override public Time getTime(String columnLabel, Calendar cal) throws SQLException { return getTime(columnLabel); } - @Override public Timestamp getTimestamp(int columnIndex, Calendar cal) throws SQLException { return getTimestamp(columnIndex); } - @Override public Timestamp getTimestamp(String columnLabel, Calendar cal) throws SQLException { return getTimestamp(columnLabel); } + @Override + public Date getDate(int columnIndex, Calendar cal) throws SQLException { + if (cal == null) { + return getDate(columnIndex); + } + checkReadable(columnIndex); + return dateValue(getCurrentValue(columnIndex), zone(cal)); + } + + @Override public Date getDate(String columnLabel, Calendar cal) throws SQLException { return getDate(findColumn(columnLabel), cal); } + + @Override + public Time getTime(int columnIndex, Calendar cal) throws SQLException { + if (cal == null) { + return getTime(columnIndex); + } + checkReadable(columnIndex); + return timeValue(getCurrentValue(columnIndex), zone(cal)); + } + + @Override public Time getTime(String columnLabel, Calendar cal) throws SQLException { return getTime(findColumn(columnLabel), cal); } + + @Override + public Timestamp getTimestamp(int columnIndex, Calendar cal) throws SQLException { + if (cal == null) { + return getTimestamp(columnIndex); + } + checkReadable(columnIndex); + return timestampValue(getCurrentValue(columnIndex), zone(cal)); + } + + @Override public Timestamp getTimestamp(String columnLabel, Calendar cal) throws SQLException { return getTimestamp(findColumn(columnLabel), cal); } + + private static ZoneId zone(Calendar cal) { + return cal.getTimeZone().toZoneId(); + } @Override public URL getURL(int columnIndex) throws SQLException { checkReadable(columnIndex); @@ -1165,6 +1221,11 @@ private static LocalDateTime dateTimeValue(Value value, String targetType) throw return SqliteDateTimeValues.from(value, targetType); } + private static LocalDateTime dateTimeValue(Value value, String targetType, ZoneId zone) + throws SQLException { + return SqliteDateTimeValues.from(value, targetType, zone); + } + @Override public T getObject(String columnLabel, Class type) throws SQLException { return getObject(findColumn(columnLabel), type); diff --git a/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/SqliteDateTimeValues.java b/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/SqliteDateTimeValues.java index d0381d0..d1fc403 100644 --- a/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/SqliteDateTimeValues.java +++ b/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/SqliteDateTimeValues.java @@ -20,22 +20,35 @@ final class SqliteDateTimeValues { private SqliteDateTimeValues() {} static LocalDateTime from(Value value, String targetType) throws SQLException { + return from(value, targetType, ZoneId.systemDefault()); + } + + /** + * Converts a value into the wall-clock fields of {@code zone}. An INTEGER or FLOAT value is a + * fixed instant, and text with an explicit offset is too; both are projected into {@code zone}. + * Naive text is wall-clock time in {@code zone}. + */ + static LocalDateTime from(Value value, String targetType, ZoneId zone) throws SQLException { return switch (value) { case Value.Null n -> null; - case Value.Integer i -> epochMillisToLocalDateTime(i.value(), targetType); + case Value.Integer i -> epochMillisToLocalDateTime(i.value(), targetType, zone); case Value.Float f -> - epochMillisToLocalDateTime(julianDayToEpochMillis(f.value()), targetType); + epochMillisToLocalDateTime(julianDayToEpochMillis(f.value()), targetType, zone); case Value.Text t -> - t.value().isBlank() ? null : parseSqliteDateTime(t.value(), targetType); + t.value().isBlank() ? null : parseSqliteDateTime(t.value(), targetType, zone); case Value.Blob b -> throw new SQLException("Bad value for type " + targetType); }; } static LocalDateTime epochMillisToLocalDateTime(long epochMillis, String targetType) throws SQLException { + return epochMillisToLocalDateTime(epochMillis, targetType, ZoneId.systemDefault()); + } + + static LocalDateTime epochMillisToLocalDateTime( + long epochMillis, String targetType, ZoneId zone) throws SQLException { try { - return LocalDateTime.ofInstant( - Instant.ofEpochMilli(epochMillis), ZoneId.systemDefault()); + return LocalDateTime.ofInstant(Instant.ofEpochMilli(epochMillis), zone); } catch (DateTimeException e) { throw new SQLException("Bad value for type " + targetType, e); } @@ -47,6 +60,11 @@ static long julianDayToEpochMillis(double julianDay) { static LocalDateTime parseSqliteDateTime(String text, String targetType) throws SQLException { + return parseSqliteDateTime(text, targetType, ZoneId.systemDefault()); + } + + static LocalDateTime parseSqliteDateTime(String text, String targetType, ZoneId zone) + throws SQLException { String value = text.trim(); ZoneOffset offset = null; if (value.endsWith("Z") || value.endsWith("z")) { @@ -91,9 +109,7 @@ static LocalDateTime parseSqliteDateTime(String text, String targetType) LocalDateTime local = LocalDateTime.of(date, time); return offset == null ? local - : local.atOffset(offset) - .atZoneSameInstant(ZoneId.systemDefault()) - .toLocalDateTime(); + : local.atOffset(offset).atZoneSameInstant(zone).toLocalDateTime(); } catch (DateTimeException | NumberFormatException e) { throw new SQLException("Bad value for type " + targetType + ": " + text, e); } diff --git a/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/LibsqlPreparedStatementTest.java b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/LibsqlPreparedStatementTest.java index 8dd2dd7..cf0b747 100644 --- a/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/LibsqlPreparedStatementTest.java +++ b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/LibsqlPreparedStatementTest.java @@ -21,6 +21,7 @@ import java.sql.Time; import java.sql.Timestamp; import java.sql.Types; +import java.time.Instant; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; @@ -28,6 +29,7 @@ import java.util.ArrayList; import java.util.Calendar; import java.util.List; +import java.util.Locale; import java.util.TimeZone; import static org.assertj.core.api.Assertions.assertThat; @@ -532,6 +534,65 @@ void temporalTargetsBindSqliteText() throws SQLException { assertThat(bind(utilDate, Types.TIMESTAMP)).isEqualTo(Value.of(expectedTimestamp)); } + @Test + void calendarSettersRenderTheInstantInTheCalendarZone() throws SQLException { + Instant instant = Instant.parse("2024-01-02T23:30:00Z"); + Timestamp timestamp = Timestamp.from(instant); + Date date = new Date(instant.toEpochMilli()); + Time time = new Time(instant.toEpochMilli()); + + assertThat(bindParameter(s -> s.setTimestamp(1, timestamp, calendar("Asia/Tokyo")))) + .isEqualTo(Value.of("2024-01-03 08:30:00.0")); + assertThat(bindParameter(s -> s.setTimestamp(1, timestamp, calendar("UTC")))) + .isEqualTo(Value.of("2024-01-02 23:30:00.0")); + assertThat(bindParameter(s -> s.setDate(1, date, calendar("Asia/Tokyo")))) + .isEqualTo(Value.of("2024-01-03")); + assertThat(bindParameter(s -> s.setDate(1, date, calendar("UTC")))) + .isEqualTo(Value.of("2024-01-02")); + assertThat(bindParameter(s -> s.setTime(1, time, calendar("Asia/Tokyo")))) + .isEqualTo(Value.of("08:30:00")); + assertThat(bindParameter(s -> s.setTime(1, time, calendar("UTC")))) + .isEqualTo(Value.of("23:30:00")); + + Calendar buddhistTokyo = Calendar.getInstance( + TimeZone.getTimeZone("Asia/Tokyo"), Locale.forLanguageTag("th-TH-u-ca-buddhist")); + assertThat(bindParameter(s -> s.setTimestamp(1, timestamp, buddhistTokyo))) + .as("only the calendar zone matters, not its field system") + .isEqualTo(Value.of("2024-01-03 08:30:00.0")); + } + + @Test + void calendarTimestampSetterKeepsTheTimestampTextForm() throws SQLException { + Timestamp withNanos = Timestamp.from(Instant.parse("2024-01-02T23:30:00.123456789Z")); + Timestamp withMillis = Timestamp.from(Instant.parse("2024-01-02T23:30:00.123Z")); + + assertThat(bindParameter(s -> s.setTimestamp(1, withNanos, calendar("Asia/Tokyo")))) + .isEqualTo(Value.of("2024-01-03 08:30:00.123456789")); + assertThat(bindParameter(s -> s.setTimestamp(1, withMillis, calendar("Asia/Tokyo")))) + .isEqualTo(Value.of("2024-01-03 08:30:00.123")); + } + + @Test + void nullCalendarSetterEqualsTheDefaultOverload() throws SQLException { + Instant instant = Instant.parse("2024-01-02T23:30:00Z"); + + assertThat(bindParameter(s -> s.setTimestamp(1, Timestamp.from(instant), null))) + .isEqualTo(bindParameter(s -> s.setTimestamp(1, Timestamp.from(instant)))); + assertThat(bindParameter(s -> s.setDate(1, new Date(instant.toEpochMilli()), null))) + .isEqualTo(bindParameter(s -> s.setDate(1, new Date(instant.toEpochMilli())))); + assertThat(bindParameter(s -> s.setTime(1, new Time(instant.toEpochMilli()), null))) + .isEqualTo(bindParameter(s -> s.setTime(1, new Time(instant.toEpochMilli())))); + } + + @Test + void calendarSettersBindNullForNullValues() throws SQLException { + Calendar tokyo = calendar("Asia/Tokyo"); + + assertThat(bindParameter(s -> s.setTimestamp(1, null, tokyo))).isEqualTo(Value.ofNull()); + assertThat(bindParameter(s -> s.setDate(1, null, tokyo))).isEqualTo(Value.ofNull()); + assertThat(bindParameter(s -> s.setTime(1, null, tokyo))).isEqualTo(Value.ofNull()); + } + @Test void lobTargetsReadTheirStreams() throws SQLException { byte[] bytes = {1, 2, 3}; @@ -611,6 +672,10 @@ private static Value bindParameter(ParameterBinder binder) throws SQLException { return connection.executed.get(0).args().get(0); } + private static Calendar calendar(String zoneId) { + return Calendar.getInstance(TimeZone.getTimeZone(zoneId)); + } + private static Calendar calendar2024() { Calendar calendar = Calendar.getInstance(TimeZone.getTimeZone("UTC")); calendar.clear(); diff --git a/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/LibsqlResultSetTest.java b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/LibsqlResultSetTest.java index 03521e4..ec7b268 100644 --- a/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/LibsqlResultSetTest.java +++ b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/LibsqlResultSetTest.java @@ -16,20 +16,25 @@ import java.sql.Array; import java.sql.Blob; import java.sql.Clob; +import java.sql.Date; import java.sql.NClob; import java.sql.Ref; import java.sql.RowId; import java.sql.SQLException; import java.sql.SQLFeatureNotSupportedException; import java.sql.SQLXML; +import java.sql.Time; +import java.sql.Timestamp; import java.time.Instant; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; import java.time.ZoneId; import java.time.ZoneOffset; +import java.util.Calendar; import java.util.List; import java.util.Map; +import java.util.TimeZone; import java.util.stream.IntStream; import static org.assertj.core.api.Assertions.assertThat; @@ -37,6 +42,9 @@ class LibsqlResultSetTest { + private static final ZoneId TOKYO = ZoneId.of("Asia/Tokyo"); + private static final ZoneId UTC = ZoneId.of("UTC"); + private static final StmtResult INTEGER_ROWS = new StmtResult( List.of(new Col("id", "INTEGER")), List.of(new Row(List.of(Value.of(42L))), new Row(List.of(Value.of(5_000_000_000L)))), @@ -307,6 +315,131 @@ void integerEpochMillisFeedsEveryDateAndTimeGetter() throws SQLException { assertDateTimeGetters(rs, 1_700_000_000_000L); } + @Test + void calendarTimestampGetterUsesTheCalendarZone() throws SQLException { + LibsqlResultSet rs = resultSet(new Value.Text("2024-01-02 23:30:00")); + assertThat(rs.next()).isTrue(); + LocalDateTime local = LocalDateTime.of(2024, 1, 2, 23, 30); + long utcMillis = local.atZone(UTC).toInstant().toEpochMilli(); + long tokyoMillis = local.atZone(TOKYO).toInstant().toEpochMilli(); + + assertThat(rs.getTimestamp(1, calendar("UTC")).getTime()).isEqualTo(utcMillis); + assertThat(rs.getTimestamp(1, calendar("Asia/Tokyo")).getTime()).isEqualTo(tokyoMillis); + assertThat(rs.getTimestamp("c1", calendar("Asia/Tokyo")).getTime()).isEqualTo(tokyoMillis); + assertThat(utcMillis - tokyoMillis).isEqualTo(9L * 60 * 60 * 1000); + } + + @Test + void calendarDateGetterProjectsMidnightInTheCalendarZone() throws SQLException { + LibsqlResultSet rs = resultSet(new Value.Text("2024-01-02 23:30:00")); + assertThat(rs.next()).isTrue(); + LocalDate date = LocalDate.of(2024, 1, 2); + long utcMillis = date.atStartOfDay(UTC).toInstant().toEpochMilli(); + long tokyoMillis = date.atStartOfDay(TOKYO).toInstant().toEpochMilli(); + + assertThat(rs.getDate(1, calendar("UTC")).getTime()).isEqualTo(utcMillis); + assertThat(rs.getDate(1, calendar("Asia/Tokyo")).getTime()).isEqualTo(tokyoMillis); + assertThat(rs.getDate("c1", calendar("Asia/Tokyo")).getTime()).isEqualTo(tokyoMillis); + } + + @Test + void calendarTimeGetterProjectsTheTimeOnTheEpochDate() throws SQLException { + LibsqlResultSet rs = resultSet(new Value.Text("2024-01-02 23:30:00")); + assertThat(rs.next()).isTrue(); + long utcMillis = LocalDate.of(1970, 1, 1) + .atTime(LocalTime.of(23, 30)) + .atZone(UTC) + .toInstant() + .toEpochMilli(); + long tokyoMillis = LocalDate.of(1970, 1, 1) + .atTime(LocalTime.of(23, 30)) + .atZone(TOKYO) + .toInstant() + .toEpochMilli(); + + assertThat(rs.getTime(1, calendar("UTC")).getTime()).isEqualTo(utcMillis); + assertThat(rs.getTime(1, calendar("Asia/Tokyo")).getTime()).isEqualTo(tokyoMillis); + assertThat(rs.getTime("c1", calendar("Asia/Tokyo")).getTime()).isEqualTo(tokyoMillis); + } + + @Test + void offsetTextIsAFixedInstantConvertedIntoTheCalendarZone() throws SQLException { + LibsqlResultSet rs = resultSet(new Value.Text("2024-01-03T08:30:00+09:00")); + assertThat(rs.next()).isTrue(); + Instant instant = Instant.parse("2024-01-02T23:30:00Z"); + + assertThat(rs.getTimestamp(1, calendar("UTC")).getTime()).isEqualTo(instant.toEpochMilli()); + assertThat(rs.getDate(1, calendar("UTC")).getTime()) + .isEqualTo(LocalDate.of(2024, 1, 2).atStartOfDay(UTC).toInstant().toEpochMilli()); + } + + @Test + void calendarTimestampGetterPreservesNanos() throws SQLException { + LibsqlResultSet rs = resultSet(new Value.Text("2024-01-02 23:30:00.123456789")); + assertThat(rs.next()).isTrue(); + + Timestamp timestamp = rs.getTimestamp(1, calendar("Asia/Tokyo")); + + assertThat(timestamp.getNanos()).isEqualTo(123_456_789); + assertThat(timestamp.getTime()) + .isEqualTo(LocalDateTime.of(2024, 1, 2, 23, 30, 0, 123_456_789) + .atZone(TOKYO) + .toInstant() + .toEpochMilli()); + } + + @Test + void integerEpochMillisIsAFixedInstantProjectedInTheCalendarZone() throws SQLException { + long epochMillis = Instant.parse("2024-01-02T20:00:00Z").toEpochMilli(); + LibsqlResultSet rs = resultSet(new Value.Integer(epochMillis)); + assertThat(rs.next()).isTrue(); + + assertThat(rs.getTimestamp(1, calendar("Asia/Tokyo")).getTime()).isEqualTo(epochMillis); + assertThat(rs.getDate(1, calendar("UTC")).getTime()) + .isEqualTo(LocalDate.of(2024, 1, 2).atStartOfDay(UTC).toInstant().toEpochMilli()); + assertThat(rs.getDate(1, calendar("Asia/Tokyo")).getTime()) + .isEqualTo(LocalDate.of(2024, 1, 3).atStartOfDay(TOKYO).toInstant().toEpochMilli()); + } + + @Test + void floatJulianDayIsAFixedInstantProjectedInTheCalendarZone() throws SQLException { + long epochMillis = Instant.parse("2024-01-02T20:00:00Z").toEpochMilli(); + double julianDay = 2440587.5 + epochMillis / 86_400_000.0; + LibsqlResultSet rs = resultSet(Value.of(julianDay)); + assertThat(rs.next()).isTrue(); + + assertThat(rs.getTimestamp(1, calendar("Asia/Tokyo")).getTime()).isEqualTo(epochMillis); + assertThat(rs.getDate(1, calendar("Asia/Tokyo")).getTime()) + .isEqualTo(LocalDate.of(2024, 1, 3).atStartOfDay(TOKYO).toInstant().toEpochMilli()); + } + + @Test + void nullCalendarGetterEqualsTheDefaultOverload() throws SQLException { + LibsqlResultSet rs = resultSet(new Value.Text("2024-01-02 23:30:00")); + assertThat(rs.next()).isTrue(); + + assertThat(rs.getTimestamp(1, null).getTime()).isEqualTo(rs.getTimestamp(1).getTime()); + assertThat(rs.getTimestamp("c1", null).getTime()).isEqualTo(rs.getTimestamp("c1").getTime()); + assertThat(rs.getDate(1, null).getTime()).isEqualTo(rs.getDate(1).getTime()); + assertThat(rs.getTime(1, null).getTime()).isEqualTo(rs.getTime(1).getTime()); + } + + @Test + void calendarGettersReturnNullForNullAndBlankValues() throws SQLException { + for (Value value : List.of(Value.ofNull(), Value.of(""))) { + LibsqlResultSet rs = resultSet(value); + assertThat(rs.next()).isTrue(); + Calendar tokyo = calendar("Asia/Tokyo"); + + assertThat(rs.getTimestamp(1, tokyo)).isNull(); + assertThat(rs.getTimestamp("c1", tokyo)).isNull(); + assertThat(rs.getDate(1, tokyo)).isNull(); + assertThat(rs.getDate("c1", tokyo)).isNull(); + assertThat(rs.getTime(1, tokyo)).isNull(); + assertThat(rs.getTime("c1", tokyo)).isNull(); + } + } + @Test void realJulianDayFeedsEveryDateAndTimeGetter() throws SQLException { LibsqlResultSet rs = resultSet(new Value.Float(2460311.5)); @@ -690,6 +823,10 @@ private static void assertNumeric(LibsqlResultSet rs, int column, int expectedIn assertThat(rs.getDouble(column)).isEqualTo(expectedDouble); } + private static Calendar calendar(String zoneId) { + return Calendar.getInstance(TimeZone.getTimeZone(zoneId)); + } + private static LibsqlResultSet resultSet(Value... values) { String[] declaredTypes = new String[values.length]; return declaredResultSet(declaredTypes, values);