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/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/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"); + } + } + } + } } 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")) {