From 47754af2bd23e34df87523f02baf50d41eed2dc4 Mon Sep 17 00:00:00 2001 From: hutiefang76 <137664623+hutiefang76@users.noreply.github.com> Date: Sat, 24 Oct 2026 18:51:51 +0800 Subject: [PATCH 1/2] Fix nanosecond precision for TIMESTAMP_NS parameters Keep Timestamp and LocalDateTime nanoseconds through typed prepared parameters and JNI, including Calendar bindings. Preserve the existing microsecond conversions for other parameter types, and reject values that overflow the nanosecond range or coincide with infinity sentinels. Add round-trip, boundary and compatibility regressions, and update the existing Appender comparison that expected the old precision loss. AI-assisted implementation and review using Codex and DeepSeek. --- src/jni/refs.cpp | 4 + src/jni/refs.hpp | 2 + src/jni/types.cpp | 4 + .../org/duckdb/DuckDBPreparedStatement.java | 6 + .../java/org/duckdb/DuckDBTimestampNS.java | 72 +++++ src/test/java/org/duckdb/TestAppender.java | 2 +- src/test/java/org/duckdb/TestTimestamp.java | 253 ++++++++++++++++++ 7 files changed, 342 insertions(+), 1 deletion(-) create mode 100644 src/main/java/org/duckdb/DuckDBTimestampNS.java diff --git a/src/jni/refs.cpp b/src/jni/refs.cpp index 2d719473f..6c8975e69 100644 --- a/src/jni/refs.cpp +++ b/src/jni/refs.cpp @@ -31,6 +31,7 @@ jclass J_String; jclass J_Timestamp; jmethodID J_Timestamp_valueOf; jclass J_TimestampTZ; +jclass J_TimestampNS; jclass J_BigDecimal; jclass J_HugeInt; jclass J_ByteArray; @@ -44,6 +45,7 @@ jmethodID J_Float_floatValue; jmethodID J_Double_doubleValue; jmethodID J_Timestamp_getMicrosEpoch; jmethodID J_TimestampTZ_getMicrosEpoch; +jmethodID J_TimestampNS_getNanosEpoch; jmethodID J_BigDecimal_precision; jmethodID J_BigDecimal_scale; jmethodID J_BigDecimal_scaleByPowTen; @@ -218,6 +220,7 @@ void create_refs(JNIEnv *env) { J_Timestamp = make_class_ref(env, "org/duckdb/DuckDBTimestamp"); J_Timestamp_valueOf = get_static_method_id(env, J_Timestamp, "valueOf", "(Ljava/lang/Object;)Ljava/lang/Object;"); J_TimestampTZ = make_class_ref(env, "org/duckdb/DuckDBTimestampTZ"); + J_TimestampNS = make_class_ref(env, "org/duckdb/DuckDBTimestampNS"); J_DuckDBDate = make_class_ref(env, "org/duckdb/DuckDBDate"); J_DuckDBDate_getDaysSinceEpoch = get_method_id(env, J_DuckDBDate, "getDaysSinceEpoch", "()J"); @@ -271,6 +274,7 @@ void create_refs(JNIEnv *env) { J_Double_doubleValue = get_method_id(env, J_Double, "doubleValue", "()D"); J_Timestamp_getMicrosEpoch = get_method_id(env, J_Timestamp, "getMicrosEpoch", "()J"); J_TimestampTZ_getMicrosEpoch = get_method_id(env, J_TimestampTZ, "getMicrosEpoch", "()J"); + J_TimestampNS_getNanosEpoch = get_method_id(env, J_TimestampNS, "getNanosEpoch", "()J"); J_BigDecimal_precision = get_method_id(env, J_BigDecimal, "precision", "()I"); J_BigDecimal_scale = get_method_id(env, J_BigDecimal, "scale", "()I"); J_BigDecimal_scaleByPowTen = get_method_id(env, J_BigDecimal, "scaleByPowerOfTen", "(I)Ljava/math/BigDecimal;"); diff --git a/src/jni/refs.hpp b/src/jni/refs.hpp index 87f15ab6f..2ecc9153d 100644 --- a/src/jni/refs.hpp +++ b/src/jni/refs.hpp @@ -28,6 +28,7 @@ extern jclass J_String; extern jclass J_Timestamp; extern jmethodID J_Timestamp_valueOf; extern jclass J_TimestampTZ; +extern jclass J_TimestampNS; extern jclass J_BigDecimal; extern jclass J_HugeInt; extern jclass J_ByteArray; @@ -41,6 +42,7 @@ extern jmethodID J_Float_floatValue; extern jmethodID J_Double_doubleValue; extern jmethodID J_Timestamp_getMicrosEpoch; extern jmethodID J_TimestampTZ_getMicrosEpoch; +extern jmethodID J_TimestampNS_getNanosEpoch; extern jmethodID J_BigDecimal_precision; extern jmethodID J_BigDecimal_scale; extern jmethodID J_BigDecimal_scaleByPowTen; diff --git a/src/jni/types.cpp b/src/jni/types.cpp index 23a516e1d..0d71e2ad1 100644 --- a/src/jni/types.cpp +++ b/src/jni/types.cpp @@ -224,6 +224,10 @@ duckdb::Value to_duckdb_value(JNIEnv *env, jobject param, duckdb::ClientContext jlong val = env->CallLongMethod(param, J_Timestamp_getMicrosEpoch); check_java_exception_and_rethrow(env); return duckdb::Value::TIME(static_cast(val)); + } else if (env->IsInstanceOf(param, J_TimestampNS)) { // Check for subclass before superclass! + jlong val = env->CallLongMethod(param, J_TimestampNS_getNanosEpoch); + check_java_exception_and_rethrow(env); + return duckdb::Value::TIMESTAMPNS(static_cast(val)); } else if (env->IsInstanceOf(param, J_Timestamp)) { jlong val = env->CallLongMethod(param, J_Timestamp_getMicrosEpoch); check_java_exception_and_rethrow(env); diff --git a/src/main/java/org/duckdb/DuckDBPreparedStatement.java b/src/main/java/org/duckdb/DuckDBPreparedStatement.java index e2875780d..422791b1c 100644 --- a/src/main/java/org/duckdb/DuckDBPreparedStatement.java +++ b/src/main/java/org/duckdb/DuckDBPreparedStatement.java @@ -436,6 +436,12 @@ public void setObject(int parameterIndex, Object x) throws SQLException { if (x instanceof BigInteger) { x = new DuckDBHugeInt((BigInteger) x); } + // TIMESTAMP_NS parameters keep the full nanosecond value; every other type keeps the regular conversion. + if ((x instanceof Timestamp || x instanceof LocalDateTime) && + "TIMESTAMP_NS".equals(getParameterMetaData().getParameterTypeName(parameterIndex))) { + x = x instanceof Timestamp ? new DuckDBTimestampNS(((Timestamp) x).toLocalDateTime()) + : new DuckDBTimestampNS((LocalDateTime) x); + } params[parameterIndex - 1] = x; } diff --git a/src/main/java/org/duckdb/DuckDBTimestampNS.java b/src/main/java/org/duckdb/DuckDBTimestampNS.java new file mode 100644 index 000000000..b398cbd39 --- /dev/null +++ b/src/main/java/org/duckdb/DuckDBTimestampNS.java @@ -0,0 +1,72 @@ +package org.duckdb; + +import static org.duckdb.JdbcUtils.createSQLException; + +import java.sql.SQLException; +import java.time.LocalDateTime; +import java.time.ZoneOffset; + +/** + * Internal carrier for a TIMESTAMP_NS parameter value. + * + *

{@link DuckDBTimestamp} only keeps microseconds since the epoch, so binding a raw nanosecond value through it + * silently loses the sub-microsecond part. This subclass keeps the exact epoch nanoseconds and is converted by the JNI + * layer into a native {@code TIMESTAMP_NS} value instead of a {@code TIMESTAMP} one. + * + *

Package private on purpose: instances are only created by {@link DuckDBPreparedStatement} for parameters whose + * metadata type is exactly TIMESTAMP_NS, and only read back through {@link #getNanosEpoch()} from the JNI layer. + */ +final class DuckDBTimestampNS extends DuckDBTimestamp { + + private static final long NANOS_PER_SECOND = 1_000_000_000L; + private static final long NANOS_PER_MICRO = 1_000L; + + private final long epochNanos; + + DuckDBTimestampNS(LocalDateTime localDateTime) throws SQLException { + this(toEpochNanos(localDateTime)); + } + + private DuckDBTimestampNS(long epochNanos) throws SQLException { + // The infinity sentinels are rejected before the microsecond floor of the super constructor runs, so an + // unsupported value is always reported as a TIMESTAMP_NS range error. + super(Math.floorDiv(checkedNanos(epochNanos), NANOS_PER_MICRO)); + this.epochNanos = epochNanos; + } + + long getNanosEpoch() { + return epochNanos; + } + + private static long toEpochNanos(LocalDateTime localDateTime) throws SQLException { + long epochSecond = localDateTime.toEpochSecond(ZoneOffset.UTC); + int nanoOfSecond = localDateTime.getNano(); + try { + if (epochSecond < 0 && nanoOfSecond > 0) { + // epochSecond * 1e9 alone overflows for representable values near the negative boundary, so move one + // second into the nanosecond part and let the exact integer arithmetic do the carry. + return Math.addExact(Math.multiplyExact(epochSecond + 1, NANOS_PER_SECOND), + nanoOfSecond - NANOS_PER_SECOND); + } + return Math.addExact(Math.multiplyExact(epochSecond, NANOS_PER_SECOND), (long) nanoOfSecond); + } catch (ArithmeticException e) { + throw outOfRange(localDateTime, e); + } + } + + private static long checkedNanos(long epochNanos) throws SQLException { + // Only +/-Long.MAX_VALUE are reserved: native timestamp_base_t uses them as the infinity sentinels, every + // other int64 value (including Long.MIN_VALUE) is a finite TIMESTAMP_NS. + if (epochNanos == Long.MAX_VALUE || epochNanos == -Long.MAX_VALUE) { + throw outOfRange(null, null); + } + return epochNanos; + } + + private static SQLException outOfRange(LocalDateTime localDateTime, Throwable cause) { + String value = localDateTime == null ? "" : " (value: " + localDateTime + ")"; + return createSQLException("Value is out of range for TIMESTAMP_NS: nanoseconds since the epoch must be a " + + "finite timestamp" + value, + ErrorCode.PREPARED_CONVERSION, cause); + } +} diff --git a/src/test/java/org/duckdb/TestAppender.java b/src/test/java/org/duckdb/TestAppender.java index d404aa24c..aca9d951c 100644 --- a/src/test/java/org/duckdb/TestAppender.java +++ b/src/test/java/org/duckdb/TestAppender.java @@ -268,7 +268,7 @@ public static void test_appender_timestamp_local() throws Exception { assertEquals(rs.getObject(2, LocalDateTime.class).toString(), "2025-06-25T12:34:46"); assertEquals(rs.getObject(3, LocalDateTime.class).toString(), "2025-06-25T12:34:45.679"); assertEquals(rs.getObject(4, LocalDateTime.class).toString(), "2025-06-25T12:34:45.678901"); - assertEquals(rs.getObject(5, LocalDateTime.class).toString(), "2025-06-25T12:34:45.678901"); + assertEquals(rs.getObject(5, LocalDateTime.class).toString(), "2025-06-25T12:34:45.678901234"); assertTrue(rs.next()); assertEquals(rs.getInt(1), 42); assertEquals(rs.getObject(2, LocalDateTime.class).toString(), "2025-06-25T12:34:45"); diff --git a/src/test/java/org/duckdb/TestTimestamp.java b/src/test/java/org/duckdb/TestTimestamp.java index 27f6f9737..fce41d330 100644 --- a/src/test/java/org/duckdb/TestTimestamp.java +++ b/src/test/java/org/duckdb/TestTimestamp.java @@ -851,6 +851,259 @@ public static void test_timestamp_read_ts_from_date() throws Exception { } } + // Safe epoch-nanoseconds -> UTC LocalDateTime conversion; Long.MAX_VALUE / -Long.MAX_VALUE are the TIMESTAMP_NS + // infinity sentinels in the native representation, so only finite values are bound. + private static LocalDateTime utcFromEpochNanos(long epochNanos) { + return LocalDateTime.ofEpochSecond(Math.floorDiv(epochNanos, 1_000_000_000L), + (int) Math.floorMod(epochNanos, 1_000_000_000L), ZoneOffset.UTC); + } + + public static void test_timestamp_ns_bind_keeps_nanoseconds() throws Exception { + LocalDateTime expected = LocalDateTime.of(2026, 2, 20, 12, 0, 0, 123456789); + long expectedNanos = 1771588800123456789L; + try (Connection conn = DriverManager.getConnection(JDBC_URL); Statement stmt = conn.createStatement()) { + stmt.execute("CREATE TABLE ns_bind (id INT, ts TIMESTAMP_NS)"); + try (PreparedStatement ps = conn.prepareStatement("INSERT INTO ns_bind VALUES (?, ?)")) { + ps.setInt(1, 1); + ps.setTimestamp(2, Timestamp.valueOf(expected)); + ps.execute(); + ps.setInt(1, 2); + ps.setObject(2, Timestamp.valueOf(expected)); + ps.execute(); + ps.setInt(1, 3); + ps.setObject(2, expected); + ps.execute(); + } + try (ResultSet rs = stmt.executeQuery("SELECT epoch_ns(ts), ts FROM ns_bind ORDER BY id")) { + for (int i = 0; i < 3; i++) { + assertTrue(rs.next()); + assertEquals(expectedNanos, rs.getLong(1)); + assertEquals(expected, rs.getObject(2, LocalDateTime.class)); + } + assertEquals(expected, rs.getTimestamp(2).toLocalDateTime()); + assertFalse(rs.next()); + } + } + } + + public static void test_timestamp_ns_bind_pre_epoch_negative_fractions() throws Exception { + // -12345679ns since the epoch (-0.012345679s), and the -1ns case: fractions must floor exactly. + LocalDateTime preEpoch = LocalDateTime.of(1969, 12, 31, 23, 59, 59, 987654321); + LocalDateTime minusOneNano = LocalDateTime.of(1969, 12, 31, 23, 59, 59, 999999999); + try (Connection conn = DriverManager.getConnection(JDBC_URL); Statement stmt = conn.createStatement()) { + stmt.execute("CREATE TABLE ns_bind_pre (id INT, ts TIMESTAMP_NS)"); + try (PreparedStatement ps = conn.prepareStatement("INSERT INTO ns_bind_pre VALUES (?, ?)")) { + ps.setInt(1, 1); + ps.setTimestamp(2, Timestamp.valueOf(preEpoch)); + ps.execute(); + ps.setInt(1, 2); + ps.setObject(2, minusOneNano); + ps.execute(); + } + try (ResultSet rs = stmt.executeQuery("SELECT epoch_ns(ts), ts FROM ns_bind_pre ORDER BY id")) { + assertTrue(rs.next()); + assertEquals(-12345679L, rs.getLong(1)); + assertEquals(preEpoch, rs.getObject(2, LocalDateTime.class)); + assertTrue(rs.next()); + assertEquals(-1L, rs.getLong(1)); + assertEquals(minusOneNano, rs.getObject(2, LocalDateTime.class)); + assertFalse(rs.next()); + } + } + } + + public static void test_timestamp_ns_bind_with_calendar_wall_clock() throws Exception { + TimeZone originalTz = TimeZone.getDefault(); + TimeZone.setDefault(TimeZone.getTimeZone("UTC")); + try (Connection conn = DriverManager.getConnection(JDBC_URL); Statement stmt = conn.createStatement()) { + stmt.execute("CREATE TABLE ns_bind_cal (id INT, ts TIMESTAMP_NS)"); + Timestamp ts = Timestamp.valueOf(LocalDateTime.of(2024, 3, 5, 6, 7, 8, 123456789)); + Calendar utcCal = new GregorianCalendar(TimeZone.getTimeZone("UTC"), Locale.US); + Calendar laCal = new GregorianCalendar(TimeZone.getTimeZone("America/Los_Angeles"), Locale.US); + try (PreparedStatement ps = conn.prepareStatement("INSERT INTO ns_bind_cal VALUES (?, ?)")) { + ps.setInt(1, 1); + ps.setTimestamp(2, ts, utcCal); + ps.execute(); + ps.setInt(1, 2); + ps.setTimestamp(2, ts, laCal); + ps.execute(); + ps.setInt(1, 3); + ps.setTimestamp(2, ts, null); + ps.execute(); + } + try (ResultSet rs = stmt.executeQuery("SELECT ts FROM ns_bind_cal ORDER BY id")) { + // the calendar overload keeps the wall clock of the calendar's zone, nanoseconds included + assertTrue(rs.next()); + assertEquals(LocalDateTime.ofInstant(ts.toInstant(), ZoneId.of("UTC")), + rs.getObject(1, LocalDateTime.class)); + assertTrue(rs.next()); + assertEquals(LocalDateTime.ofInstant(ts.toInstant(), ZoneId.of("America/Los_Angeles")), + rs.getObject(1, LocalDateTime.class)); + assertTrue(rs.next()); + assertEquals(LocalDateTime.ofInstant(ts.toInstant(), ZoneId.of("UTC")), + rs.getObject(1, LocalDateTime.class)); + assertFalse(rs.next()); + } + } finally { + TimeZone.setDefault(originalTz); + } + } + + public static void test_timestamp_ns_bind_null() throws Exception { + try (Connection conn = DriverManager.getConnection(JDBC_URL); Statement stmt = conn.createStatement()) { + stmt.execute("CREATE TABLE ns_bind_null (id INT, ts TIMESTAMP_NS)"); + try (PreparedStatement ps = conn.prepareStatement("INSERT INTO ns_bind_null VALUES (?, ?)")) { + ps.setInt(1, 1); + ps.setTimestamp(2, null); + ps.execute(); + ps.setInt(1, 2); + ps.setObject(2, (Timestamp) null); + ps.execute(); + ps.setInt(1, 3); + ps.setObject(2, (LocalDateTime) null); + ps.execute(); + } + try (ResultSet rs = stmt.executeQuery("SELECT ts FROM ns_bind_null ORDER BY id")) { + for (int i = 0; i < 3; i++) { + assertTrue(rs.next()); + assertNull(rs.getObject(1)); + assertTrue(rs.wasNull()); + assertNull(rs.getObject(1, LocalDateTime.class)); + assertTrue(rs.wasNull()); + assertNull(rs.getTimestamp(1)); + assertTrue(rs.wasNull()); + } + assertFalse(rs.next()); + } + } + } + + public static void test_timestamp_ns_bind_finite_bounds() throws Exception { + // Only Long.MAX_VALUE (infinity) and -Long.MAX_VALUE (-infinity) are reserved by native timestamp_ns_t; every + // other int64 value is finite, Long.MIN_VALUE included. All finite boundaries must round-trip exactly, + // without wrapping or being truncated to microseconds. + long[] finiteBounds = {Long.MAX_VALUE - 1, Long.MIN_VALUE, -Long.MAX_VALUE + 1}; + try (Connection conn = DriverManager.getConnection(JDBC_URL); Statement stmt = conn.createStatement()) { + stmt.execute("CREATE TABLE ns_bind_bounds (id INT, ts TIMESTAMP_NS)"); + try (PreparedStatement ps = conn.prepareStatement("INSERT INTO ns_bind_bounds VALUES (?, ?)")) { + for (int i = 0; i < finiteBounds.length; i++) { + ps.setInt(1, i + 1); + ps.setTimestamp(2, Timestamp.valueOf(utcFromEpochNanos(finiteBounds[i]))); + ps.execute(); + } + } + try (ResultSet rs = stmt.executeQuery("SELECT epoch_ns(ts), ts FROM ns_bind_bounds ORDER BY id")) { + for (long bound : finiteBounds) { + assertTrue(rs.next()); + assertEquals(bound, rs.getLong(1)); + assertEquals(utcFromEpochNanos(bound), rs.getObject(2, LocalDateTime.class)); + } + assertFalse(rs.next()); + } + } + } + + public static void test_timestamp_ns_bind_out_of_range_rejected() throws Exception { + try (Connection conn = DriverManager.getConnection(JDBC_URL); Statement stmt = conn.createStatement()) { + stmt.execute("CREATE TABLE ns_bind_range (ts TIMESTAMP_NS)"); + try (PreparedStatement ps = conn.prepareStatement("INSERT INTO ns_bind_range VALUES (?)")) { + assertThrows(() -> { + ps.setTimestamp(1, Timestamp.valueOf(LocalDateTime.of(2500, 1, 2, 3, 4, 5, 678901234))); + ps.execute(); + }, SQLException.class); + } + try (PreparedStatement ps = conn.prepareStatement("INSERT INTO ns_bind_range VALUES (?)")) { + assertThrows(() -> { + ps.setObject(1, Timestamp.valueOf(LocalDateTime.of(1500, 1, 2, 3, 4, 5, 678901234))); + ps.execute(); + }, SQLException.class); + } + // out-of-range TIMESTAMP_NS bindings must not be written, truncated or wrapped + try (ResultSet rs = stmt.executeQuery("SELECT count(*) FROM ns_bind_range")) { + assertTrue(rs.next()); + assertEquals(0L, rs.getLong(1)); + } + } + } + + public static void test_timestamp_ns_bind_infinity_sentinels_rejected() throws Exception { + // The TIMESTAMP_NS infinity bit patterns (Long.MAX_VALUE / -Long.MAX_VALUE) are not finite timestamps, so + // binding them as plain Java Timestamp values must be rejected before anything is written. + long[] sentinels = {Long.MAX_VALUE, -Long.MAX_VALUE}; + try (Connection conn = DriverManager.getConnection(JDBC_URL); Statement stmt = conn.createStatement()) { + stmt.execute("CREATE TABLE ns_bind_sentinels (ts TIMESTAMP_NS)"); + for (long sentinel : sentinels) { + Timestamp ts = Timestamp.valueOf(utcFromEpochNanos(sentinel)); + try (PreparedStatement ps = conn.prepareStatement("INSERT INTO ns_bind_sentinels VALUES (?)")) { + // the rejection happens on binding; execute() is intentionally never reached with a stale value + assertThrows(() -> ps.setTimestamp(1, ts), SQLException.class); + try (ResultSet rs = stmt.executeQuery("SELECT count(*) FROM ns_bind_sentinels")) { + assertTrue(rs.next()); + assertEquals(0L, rs.getLong(1)); + } + } + } + // rejected bindings must not leave stale values behind for a later execute on the same statement + try (PreparedStatement ps = conn.prepareStatement("INSERT INTO ns_bind_sentinels VALUES (?)")) { + assertThrows( + () -> ps.setObject(1, Timestamp.valueOf(utcFromEpochNanos(Long.MAX_VALUE))), SQLException.class); + ps.setTimestamp(1, Timestamp.valueOf(LocalDateTime.of(2026, 2, 20, 12, 0, 0, 123456789))); + ps.execute(); + } + try (ResultSet rs = stmt.executeQuery("SELECT epoch_ns(ts) FROM ns_bind_sentinels")) { + assertTrue(rs.next()); + assertEquals(1771588800123456789L, rs.getLong(1)); + assertFalse(rs.next()); + } + } + } + + public static void test_timestamp_micro_bind_beyond_ns_range_unchanged() throws Exception { + // Ordinary TIMESTAMP keeps its microsecond floor and its wider date range, so dates outside the + // TIMESTAMP_NS range must still be bindable there. + try (Connection conn = DriverManager.getConnection(JDBC_URL); Statement stmt = conn.createStatement()) { + stmt.execute("CREATE TABLE micro_bind_wide (id INT, ts TIMESTAMP)"); + try (PreparedStatement ps = conn.prepareStatement("INSERT INTO micro_bind_wide VALUES (?, ?)")) { + ps.setInt(1, 1); + ps.setTimestamp(2, Timestamp.valueOf(LocalDateTime.of(2500, 1, 2, 3, 4, 5, 678901234))); + ps.execute(); + ps.setInt(1, 2); + ps.setObject(2, LocalDateTime.of(1500, 1, 2, 3, 4, 5, 678901234)); + ps.execute(); + } + try (ResultSet rs = stmt.executeQuery("SELECT ts FROM micro_bind_wide ORDER BY id")) { + assertTrue(rs.next()); + assertEquals(LocalDateTime.of(2500, 1, 2, 3, 4, 5, 678901000), rs.getObject(1, LocalDateTime.class)); + assertTrue(rs.next()); + assertEquals(LocalDateTime.of(1500, 1, 2, 3, 4, 5, 678901000), rs.getObject(1, LocalDateTime.class)); + assertFalse(rs.next()); + } + } + } + + public static void test_timestamptz_micro_precision_unchanged() throws Exception { + TimeZone originalTz = TimeZone.getDefault(); + TimeZone.setDefault(TimeZone.getTimeZone("UTC")); + try (Connection conn = DriverManager.getConnection(JDBC_URL); Statement stmt = conn.createStatement()) { + stmt.execute("SET TimeZone='UTC'"); + stmt.execute("CREATE TABLE tz_bind_micro (ts TIMESTAMPTZ)"); + OffsetDateTime odt = OffsetDateTime.of(2026, 2, 20, 12, 0, 0, 123456789, ZoneOffset.UTC); + try (PreparedStatement ps = conn.prepareStatement("INSERT INTO tz_bind_micro VALUES (?)")) { + ps.setObject(1, odt); + ps.execute(); + } + try (ResultSet rs = stmt.executeQuery("SELECT epoch_ns(ts), ts FROM tz_bind_micro")) { + assertTrue(rs.next()); + // TIMESTAMPTZ parameters stay microsecond truncated + assertEquals(1771588800123456000L, rs.getLong(1)); + assertTrue(rs.getObject(2, OffsetDateTime.class).isEqual(odt.withNano(123456000))); + assertFalse(rs.next()); + } + } finally { + TimeZone.setDefault(originalTz); + } + } + public static void test_timestamp_read_ldt_from_date() throws Exception { try (Connection conn = DriverManager.getConnection(JDBC_URL); Statement stmt = conn.createStatement(); ResultSet rs = stmt.executeQuery("SELECT '2020-01-02'::DATE")) { From 3e79703f28953a244c7c0cfe2d62ef607eee0fc7 Mon Sep 17 00:00:00 2001 From: hutiefang76 <137664623+hutiefang76@users.noreply.github.com> Date: Sat, 24 Oct 2026 20:14:54 +0800 Subject: [PATCH 2/2] Register nanosecond timestamp bindings for GraalVM JNI The startup JNI cache loads DuckDBTimestampNS even for ordinary queries. Register its class and getNanosEpoch method, and exercise Timestamp and LocalDateTime nanosecond binds in the existing native-image smoke test. Generated-by: Codex and DeepSeek V4 Flash --- .../duckdb_jdbc/reachability-metadata.json | 10 +++++ src/test/external/graal/Hello.java | 38 +++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/META-INF/native-image/org.duckdb/duckdb_jdbc/reachability-metadata.json b/META-INF/native-image/org.duckdb/duckdb_jdbc/reachability-metadata.json index 7668c0267..27d697bb9 100644 --- a/META-INF/native-image/org.duckdb/duckdb_jdbc/reachability-metadata.json +++ b/META-INF/native-image/org.duckdb/duckdb_jdbc/reachability-metadata.json @@ -477,6 +477,16 @@ } ] }, + { + "type": "org.duckdb.DuckDBTimestampNS", + "jniAccessible": true, + "methods": [ + { + "name": "getNanosEpoch", + "parameterTypes": [] + } + ] + }, { "type": "org.duckdb.DuckDBTimestampTZ", "jniAccessible": true, diff --git a/src/test/external/graal/Hello.java b/src/test/external/graal/Hello.java index d6e305c6a..c304dfc71 100644 --- a/src/test/external/graal/Hello.java +++ b/src/test/external/graal/Hello.java @@ -1,4 +1,5 @@ import java.sql.*; +import java.time.LocalDateTime; public class Hello { public static void main(String[] a) { @@ -10,6 +11,7 @@ public static void main(String[] a) { System.out.println(r.getInt(1)); } } + checkTimestampNs(); } catch (Throwable t) { // The default uncaught exception handler can fail to print in a native image, // masking the real cause, so report it explicitly here. @@ -27,4 +29,40 @@ public static void main(String[] a) { System.exit(1); } } + + private static void checkTimestampNs() throws Exception { + LocalDateTime expected = LocalDateTime.of(2026, 2, 20, 12, 0, 0, 123456789); + long expectedNanos = 1771588800123456789L; + try (Connection c = DriverManager.getConnection("jdbc:duckdb:"); Statement s = c.createStatement()) { + s.execute("CREATE TABLE ns_bind (id INT, ts TIMESTAMP_NS)"); + try (PreparedStatement ps = c.prepareStatement("INSERT INTO ns_bind VALUES (?, ?)")) { + ps.setInt(1, 1); + ps.setTimestamp(2, Timestamp.valueOf(expected)); + ps.execute(); + ps.setInt(1, 2); + ps.setObject(2, expected); + ps.execute(); + } + try (ResultSet r = s.executeQuery("SELECT epoch_ns(ts), ts FROM ns_bind ORDER BY id")) { + for (int i = 0; i < 2; i++) { + if (!r.next()) { + throw new IllegalStateException("TIMESTAMP_NS round trip returned no row " + i); + } + long actual = r.getLong(1); + if (actual != expectedNanos) { + throw new IllegalStateException("TIMESTAMP_NS epoch_ns mismatch on row " + i + ": expected " + + expectedNanos + ", actual " + actual); + } + LocalDateTime readBack = r.getObject(2, LocalDateTime.class); + if (!expected.equals(readBack)) { + throw new IllegalStateException("TIMESTAMP_NS read back mismatch on row " + i + ": expected " + + expected + ", actual " + readBack); + } + } + if (r.next()) { + throw new IllegalStateException("TIMESTAMP_NS round trip returned extra rows"); + } + } + } + } }