From 807550e6e98b695e786949b6a11fa8332860d9eb Mon Sep 17 00:00:00 2001 From: Alex Kasko Date: Mon, 15 Sep 2025 18:34:30 +0100 Subject: [PATCH] Fix timestamp before epoch shift with micros/nanos This PR fixes the problem with converting `TIMESTAMP` values into `LocalDateTime` where, when micros/nanos part was present, the calculation may have been shifted by 1 second. Fixes: #336 --- src/main/java/org/duckdb/DuckDBTimestamp.java | 24 +++----------- src/test/java/org/duckdb/TestDuckDBJDBC.java | 2 +- src/test/java/org/duckdb/TestTimestamp.java | 32 +++++++++++++++++-- 3 files changed, 34 insertions(+), 24 deletions(-) diff --git a/src/main/java/org/duckdb/DuckDBTimestamp.java b/src/main/java/org/duckdb/DuckDBTimestamp.java index a2491b311..920a2ea40 100644 --- a/src/main/java/org/duckdb/DuckDBTimestamp.java +++ b/src/main/java/org/duckdb/DuckDBTimestamp.java @@ -40,13 +40,13 @@ private static Instant createInstant(long value, ChronoUnit unit) throws SQLExce case MILLIS: return Instant.ofEpochMilli(value); case MICROS: { - long epochSecond = value / 1_000_000; - int nanoAdjustment = nanosPartMicros(value); + long epochSecond = value / 1_000_000L; + long nanoAdjustment = (value % 1_000_000L) * 1000L; return Instant.ofEpochSecond(epochSecond, nanoAdjustment); } case NANOS: { - long epochSecond = value / 1_000_000_000; - long nanoAdjustment = nanosPartNanos(value); + long epochSecond = value / 1_000_000_000L; + long nanoAdjustment = value % 1_000_000_000L; return Instant.ofEpochSecond(epochSecond, nanoAdjustment); } default: @@ -166,20 +166,4 @@ private static int nanosPartMicros(long micros) { return (int) ((1000_000L + (micros % 1000_000L)) * 1000); } } - - private static long nanos2seconds(long nanos) { - if ((nanos % 1_000_000_000L) >= 0) { - return nanos / 1_000_000_000L; - } else { - return (nanos / 1_000_000_000L) - 1; - } - } - - private static int nanosPartNanos(long nanos) { - if ((nanos % 1_000_000_000L) >= 0) { - return (int) ((nanos % 1_000_000_000L)); - } else { - return (int) ((1_000_000_000L + (nanos % 1_000_000_000L))); - } - } } diff --git a/src/test/java/org/duckdb/TestDuckDBJDBC.java b/src/test/java/org/duckdb/TestDuckDBJDBC.java index f772bf9ed..0c8dd44f7 100644 --- a/src/test/java/org/duckdb/TestDuckDBJDBC.java +++ b/src/test/java/org/duckdb/TestDuckDBJDBC.java @@ -2283,7 +2283,7 @@ private static OffsetDateTime localDateTimeToOffset(LocalDateTime ldt) { localDateTimeToOffset(LocalDateTime.ofInstant( Instant.parse("+294247-01-10T04:00:54.775807Z"), ZoneId.systemDefault())), localDateTimeToOffset(LocalDateTime.ofInstant( - Instant.parse("-290308-12-21T19:59:06.224193Z"), ZoneId.systemDefault())), + Instant.parse("-290308-12-21T19:59:05.224193Z"), ZoneId.systemDefault())), null, localDateTimeToOffset(LocalDateTime.ofInstant(Instant.parse("2022-05-12T23:23:45Z"), ZoneId.systemDefault())))); diff --git a/src/test/java/org/duckdb/TestTimestamp.java b/src/test/java/org/duckdb/TestTimestamp.java index 1003d6c78..a433ab0b7 100644 --- a/src/test/java/org/duckdb/TestTimestamp.java +++ b/src/test/java/org/duckdb/TestTimestamp.java @@ -70,7 +70,7 @@ public static void test_timestamp_tz() throws Exception { OffsetDateTime odt1 = OffsetDateTime.of(2020, 10, 7, 13, 15, 7, 12345, ZoneOffset.ofHours(7)); OffsetDateTime odt1Rounded = OffsetDateTime.of(2020, 10, 7, 13, 15, 7, 12000, ZoneOffset.ofHours(7)); OffsetDateTime odt2 = OffsetDateTime.of(1878, 10, 2, 1, 15, 7, 12345, ZoneOffset.ofHours(-5)); - OffsetDateTime odt2Rounded = OffsetDateTime.of(1878, 10, 2, 1, 15, 8, 13000, ZoneOffset.ofHours(-5)); + OffsetDateTime odt2Rounded = OffsetDateTime.of(1878, 10, 2, 1, 15, 7, 13000, ZoneOffset.ofHours(-5)); OffsetDateTime odt3 = OffsetDateTime.of(2022, 1, 1, 12, 11, 10, 0, ZoneOffset.ofHours(2)); OffsetDateTime odt4 = OffsetDateTime.of(2022, 1, 1, 12, 11, 10, 0, ZoneOffset.ofHours(0)); OffsetDateTime odt5 = OffsetDateTime.of(1900, 11, 27, 23, 59, 59, 0, ZoneOffset.ofHours(1)); @@ -102,7 +102,7 @@ public static void test_timestamp_tz() throws Exception { // Metadata tests assertEquals(Types.TIMESTAMP_WITH_TIMEZONE, DuckDBResultSetMetaData.type_to_int(DuckDBColumnType.TIMESTAMP_WITH_TIME_ZONE)); - assertTrue(OffsetDateTime.class.getName().equals(meta.getColumnClassName(2))); + assertEquals(OffsetDateTime.class.getName(), meta.getColumnClassName(2)); } } finally { TimeZone.setDefault(defaultTimeZone); @@ -296,7 +296,7 @@ public static void duckdb_timestamp_test() throws Exception { ps.setTimestamp(1, Timestamp.valueOf("1905-11-02 07:59:58.12345")); try (ResultSet rs6 = ps.executeQuery()) { assertTrue(rs6.next()); - assertEquals(rs6.getTimestamp(1), Timestamp.valueOf("1905-11-02 07:59:59.12345")); + assertEquals(rs6.getTimestamp(1), Timestamp.valueOf("1905-11-02 07:59:58.12345")); } } } @@ -555,4 +555,30 @@ public static void test_bug532_timestamp() throws Exception { } } } + + public static void test_timestamp_before_epoch() throws Exception { + TimeZone defaultTimeZone = TimeZone.getDefault(); + TimeZone activeTimeZone = TimeZone.getTimeZone("Europe/Sofia"); + TimeZone.setDefault(activeTimeZone); + try (Connection conn = DriverManager.getConnection(JDBC_URL); Statement stmt = conn.createStatement()) { + + try (ResultSet rs = stmt.executeQuery("SELECT TIMESTAMP '1969-01-01 00:00:00.123456'")) { + rs.next(); + assertEquals(rs.getObject(1, LocalDateTime.class), LocalDateTime.of(1969, 1, 1, 0, 0, 0, 123456000)); + } + + try (ResultSet rs = stmt.executeQuery("SELECT TIMESTAMP_NS '1969-01-01 00:00:00.123456789'")) { + rs.next(); + assertEquals(rs.getObject(1, LocalDateTime.class), LocalDateTime.of(1969, 1, 1, 0, 0, 0, 123456789)); + } + + try (ResultSet rs = stmt.executeQuery("SELECT TIMESTAMP WITH TIME ZONE '1969-01-01 00:00:00.123456Z'")) { + rs.next(); + assertEquals(rs.getObject(1, LocalDateTime.class), LocalDateTime.of(1969, 1, 1, 2, 0, 0, 123456000)); + } + + } finally { + TimeZone.setDefault(defaultTimeZone); + } + } }