diff --git a/CHANGELOG.md b/CHANGELOG.md index 52c92a57a..eecc1182c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,13 @@ ### Bug Fixes +- **[jdbc-v2]** Fixed `ResultSet.getTimestamp` (and `getObject(..., Timestamp.class)`) ignoring the column timezone + for `DateTime` / `DateTime32` / `DateTime64` values. The binary reader already attaches the column (or + server/session) timezone to the internal `ZonedDateTime`; converting via the caller's `Calendar` / JVM default + re-interpreted wall-clock components and shifted the returned epoch when that timezone differed from the + column's (e.g. `DateTime64(3, 'UTC')` under a non-UTC JVM, or two columns with different declared timezones). + Reading now preserves the absolute instant, so writing a `Timestamp` and reading it back from columns with + different timezones returns the same epoch millis. (https://github.com/ClickHouse/clickhouse-java/issues/2787) - **[client-v2]** Fixed LZ4 input streams not closing their underlying HTTP response stream. Closing an LZ4 stream returned by `QueryResponse.getInputStream()` now releases the wrapped transport stream, including after a partial read. (https://github.com/ClickHouse/clickhouse-java/issues/2985) diff --git a/client-v2/src/test/java/com/clickhouse/client/api/DataTypeUtilsTests.java b/client-v2/src/test/java/com/clickhouse/client/api/DataTypeUtilsTests.java index 54f9a7000..d79ba44fc 100644 --- a/client-v2/src/test/java/com/clickhouse/client/api/DataTypeUtilsTests.java +++ b/client-v2/src/test/java/com/clickhouse/client/api/DataTypeUtilsTests.java @@ -437,6 +437,28 @@ void testToSqlTimestampWithTimeZone() { assertEquals(roundTrip, localDateTime); } + /** + * Same absolute instant, different column timezones: converting via each column zone + * (as getTimestamp does) must yield the same epoch millis. + */ + @Test(groups = {"unit"}) + void testToSqlTimestampSameInstantAcrossColumnTimezones() { + Instant instant = Instant.parse("2026-03-12T14:58:08.123Z"); + String[] columnZones = {"UTC", "Asia/Tokyo", "America/New_York"}; + + long[] epochs = new long[columnZones.length]; + for (int i = 0; i < columnZones.length; i++) { + TimeZone columnTz = TimeZone.getTimeZone(columnZones[i]); + LocalDateTime wallClock = LocalDateTime.ofInstant(instant, columnTz.toZoneId()); + Timestamp ts = DataTypeUtils.toSqlTimestamp(wallClock, columnTz); + epochs[i] = ts.getTime(); + assertEquals(ts.toInstant().toEpochMilli(), instant.toEpochMilli(), + "column zone " + columnZones[i]); + } + assertEquals(epochs[0], epochs[1]); + assertEquals(epochs[1], epochs[2]); + } + @Test(groups = {"unit"}) void testToSqlTimestampPreservesNanoseconds() { LocalDateTime localDateTime = LocalDateTime.of(2024, 6, 15, 10, 30, 45, 123456789); diff --git a/docs/features.md b/docs/features.md index 915e8ac9e..1752efb00 100644 --- a/docs/features.md +++ b/docs/features.md @@ -108,6 +108,7 @@ Compatibility-sensitive traits: - Stream and reader setters (`setAsciiStream`, `setUnicodeStream`, `setBinaryStream`, `setCharacterStream`, `setNCharacterStream`) are treated as text input encoded with the same string-escaping rules, including length-based truncation when a length is supplied. - `getString()` formatting for temporal values is stable output: `Date` uses `yyyy-MM-dd`, `DateTime` uses `yyyy-MM-dd HH:mm:ss`, and `DateTime64` preserves fractional precision, all interpreted in server timezone context where applicable. - Date and timestamp setters with `Calendar` are timezone-sensitive by design. Preserving the current day-shift and instant-preserving behavior is important for compatibility. +- `ResultSet.getTimestamp` / `getObject(..., Timestamp.class)` for `DateTime`/`DateTime32`/`DateTime64` preserve the absolute instant encoded on the wire. The binary reader attaches the column (or server/session) timezone to the internal `ZonedDateTime`, and conversion to `java.sql.Timestamp` uses that zone — not the caller `Calendar` or JVM default — so columns with different declared timezones return the same epoch millis for the same stored instant. Caller `Calendar` remains on the API for JDBC compatibility but does not re-shift zoned values. - `setObject()` temporal behavior is specific and should not drift: `LocalDateTime` and `Instant` are rendered through `fromUnixTimestamp64Nano(...)`, while `Timestamp` and `Date` use quoted textual forms. - JDBC `ssl_mode` handling is compatibility-sensitive: values are case-insensitive, `none` is aliased to `trust` (the no-verification mode), and an unrecognized value throws `SQLException` during connection configuration. The normalized canonical mode name is forwarded to the underlying `client-v2` transport. - Connection `Properties` values must be strings, with one scoped exception: the `ssl_context` key may carry a live `javax.net.ssl.SSLContext` object. Any other non-string property value still throws `IllegalArgumentException` during connection configuration. A string `ssl_context` (supplied via `setProperty` or a URL query parameter) is rejected with `SQLException`, since a string cannot represent a live context. diff --git a/jdbc-v2/src/main/java/com/clickhouse/jdbc/ResultSetImpl.java b/jdbc-v2/src/main/java/com/clickhouse/jdbc/ResultSetImpl.java index cd4406cb6..29b328ebd 100644 --- a/jdbc-v2/src/main/java/com/clickhouse/jdbc/ResultSetImpl.java +++ b/jdbc-v2/src/main/java/com/clickhouse/jdbc/ResultSetImpl.java @@ -42,6 +42,7 @@ import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.TimeZone; import java.util.concurrent.TimeUnit; import java.util.function.Consumer; @@ -1119,7 +1120,16 @@ public Timestamp getTimestamp(int columnIndex, Calendar cal) throws SQLException } wasNull = false; - return DataTypeUtils.toSqlTimestamp(zdt.toLocalDateTime(), cal.getTimeZone()); + // DateTime is stored as an absolute epoch on the wire. BinaryStreamReader already + // attaches the column (or server/session) timezone when building the ZonedDateTime. + // Convert using that zone so the returned Timestamp keeps the same instant; reinterpreting + // wall-clock components with the caller Calendar shifts the epoch when the column + // timezone differs from the Calendar timezone (e.g. DateTime64(3, 'UTC') under a non-UTC + // JVM default). Calendar remains on the API for JDBC compatibility but is not used to + // shift a zoned value — matching the JDBC contract that Calendar applies only when the + // underlying type has no timezone information. + return DataTypeUtils.toSqlTimestamp(zdt.toLocalDateTime(), + TimeZone.getTimeZone(zdt.getZone())); } catch (Exception e) { ClickHouseColumn column = getSchema().getColumnByIndex(columnIndex); switch (column.getValueDataType()) { diff --git a/jdbc-v2/src/main/java/com/clickhouse/jdbc/internal/DetachedResultSet.java b/jdbc-v2/src/main/java/com/clickhouse/jdbc/internal/DetachedResultSet.java index 5c13569b1..19b44e4dc 100644 --- a/jdbc-v2/src/main/java/com/clickhouse/jdbc/internal/DetachedResultSet.java +++ b/jdbc-v2/src/main/java/com/clickhouse/jdbc/internal/DetachedResultSet.java @@ -25,7 +25,6 @@ import java.sql.Timestamp; import java.time.Instant; import java.time.LocalDate; -import java.time.LocalDateTime; import java.time.LocalTime; import java.time.ZonedDateTime; import java.util.ArrayList; @@ -838,18 +837,11 @@ public Timestamp getTimestamp(int columnIndex, Calendar cal) throws SQLException public Timestamp getTimestamp(String columnLabel, Calendar cal) throws SQLException { ensureOpen(); try { - Timestamp timestamp = getObject(columnLabel, Timestamp.class); - if (timestamp != null) { - Calendar c = (Calendar) (cal != null ? cal : defaultCalendar).clone(); - c.clear(); - LocalDateTime ldt = timestamp.toLocalDateTime(); - c.set(ldt.getYear(), ldt.getMonthValue() - 1, ldt.getDayOfMonth(), ldt.getHour(), ldt.getMinute(), - ldt.getSecond()); - timestamp = new Timestamp(c.getTimeInMillis()); - timestamp.setNanos(ldt.getNano()); - return timestamp; - } - return timestamp; + // Values are materialised as absolute java.sql.Timestamp instants when the parent + // result set is detached. Do not re-interpret wall-clock components with Calendar — + // that would shift the epoch when the Calendar timezone differs from the JVM default + // (and from the original column timezone). Calendar is kept for JDBC API compatibility. + return getObject(columnLabel, Timestamp.class); } catch (Exception e) { throw new SQLException(String.format("Method: getTimestamp(\"%s\") encountered an exception.", columnLabel), e); } diff --git a/jdbc-v2/src/main/java/com/clickhouse/jdbc/internal/JdbcUtils.java b/jdbc-v2/src/main/java/com/clickhouse/jdbc/internal/JdbcUtils.java index dff5b4449..296598dd1 100644 --- a/jdbc-v2/src/main/java/com/clickhouse/jdbc/internal/JdbcUtils.java +++ b/jdbc-v2/src/main/java/com/clickhouse/jdbc/internal/JdbcUtils.java @@ -395,6 +395,22 @@ static Object convertObject(Object value, Class type, ClickHouseColumn column } else if (type == Date.class) { return Date.valueOf(LocalDate.from(temporalValue)); } else if (type == java.sql.Timestamp.class) { + // Preserve the absolute instant for types that carry a zone/offset. Using + // Timestamp.valueOf(LocalDateTime) would re-interpret wall-clock components in + // the JVM default zone and shift the epoch when it differs from the column zone. + if (temporalValue instanceof ZonedDateTime) { + ZonedDateTime zdt = (ZonedDateTime) temporalValue; + java.sql.Timestamp ts = java.sql.Timestamp.from(zdt.toInstant()); + ts.setNanos(zdt.getNano()); + return ts; + } else if (temporalValue instanceof OffsetDateTime) { + OffsetDateTime odt = (OffsetDateTime) temporalValue; + java.sql.Timestamp ts = java.sql.Timestamp.from(odt.toInstant()); + ts.setNanos(odt.getNano()); + return ts; + } else if (temporalValue instanceof Instant) { + return java.sql.Timestamp.from((Instant) temporalValue); + } return java.sql.Timestamp.valueOf(LocalDateTime.from(temporalValue)); } else if (type == java.sql.Time.class) { return java.sql.Time.valueOf(LocalTime.from(temporalValue)); diff --git a/jdbc-v2/src/test/java/com/clickhouse/jdbc/JDBCDateTimeTests.java b/jdbc-v2/src/test/java/com/clickhouse/jdbc/JDBCDateTimeTests.java index 1c74cb4bb..b275d55fd 100644 --- a/jdbc-v2/src/test/java/com/clickhouse/jdbc/JDBCDateTimeTests.java +++ b/jdbc-v2/src/test/java/com/clickhouse/jdbc/JDBCDateTimeTests.java @@ -13,6 +13,7 @@ import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Statement; +import java.sql.Timestamp; import java.time.Duration; import java.time.Instant; import java.time.LocalDate; @@ -308,4 +309,52 @@ void testTimestampInRange() throws Exception { } } } + + @Test(groups = {"integration"}) + void testGetTimestampPreservesInstantAcrossColumnTimezones() throws Exception { + try (Connection conn = getJdbcConnection(); + Statement stmt = conn.createStatement()) { + stmt.execute("DROP TABLE IF EXISTS test_timestamp_column_tz"); + stmt.execute("CREATE TABLE test_timestamp_column_tz (" + + " t_tokyo DateTime64(3, 'Asia/Tokyo'), " + + " t_ny DateTime64(3, 'America/New_York'), " + + " t_utc DateTime64(3, 'UTC') " + + ") ENGINE = MergeTree ORDER BY t_tokyo"); + + Timestamp original = new Timestamp(1_773_356_288_000L); // 2026-03-12T14:58:08Z + try (PreparedStatement pstmt = conn.prepareStatement( + "INSERT INTO test_timestamp_column_tz (t_tokyo, t_ny, t_utc) VALUES (?, ?, ?)")) { + pstmt.setTimestamp(1, original); + pstmt.setTimestamp(2, original); + pstmt.setTimestamp(3, original); + pstmt.executeUpdate(); + } + + try (ResultSet rs = stmt.executeQuery("SELECT t_tokyo, t_ny, t_utc FROM test_timestamp_column_tz")) { + Assert.assertTrue(rs.next()); + + Timestamp readTokyo = rs.getTimestamp("t_tokyo"); + Timestamp readNy = rs.getTimestamp("t_ny"); + Timestamp readUtc = rs.getTimestamp("t_utc"); + + Assert.assertEquals(readTokyo.getTime(), original.getTime()); + Assert.assertEquals(readNy.getTime(), original.getTime()); + Assert.assertEquals(readUtc.getTime(), original.getTime()); + Assert.assertEquals(readTokyo.toInstant(), original.toInstant()); + Assert.assertEquals(readNy.toInstant(), original.toInstant()); + Assert.assertEquals(readUtc.toInstant(), original.toInstant()); + + // Calendar must not re-shift a zoned DateTime column. + Calendar tokyoCal = Calendar.getInstance(TimeZone.getTimeZone("Asia/Tokyo")); + Calendar nyCal = Calendar.getInstance(TimeZone.getTimeZone("America/New_York")); + Assert.assertEquals(rs.getTimestamp("t_tokyo", tokyoCal).getTime(), original.getTime()); + Assert.assertEquals(rs.getTimestamp("t_tokyo", nyCal).getTime(), original.getTime()); + Assert.assertEquals(rs.getTimestamp("t_ny", tokyoCal).getTime(), original.getTime()); + Assert.assertEquals(rs.getTimestamp("t_ny", nyCal).getTime(), original.getTime()); + + Assert.assertEquals(rs.getObject("t_tokyo", Timestamp.class).getTime(), original.getTime()); + Assert.assertEquals(rs.getObject("t_ny", Timestamp.class).getTime(), original.getTime()); + } + } + } } diff --git a/jdbc-v2/src/test/java/com/clickhouse/jdbc/internal/JdbcUtilsTest.java b/jdbc-v2/src/test/java/com/clickhouse/jdbc/internal/JdbcUtilsTest.java index 315093cff..3670db2fa 100644 --- a/jdbc-v2/src/test/java/com/clickhouse/jdbc/internal/JdbcUtilsTest.java +++ b/jdbc-v2/src/test/java/com/clickhouse/jdbc/internal/JdbcUtilsTest.java @@ -13,7 +13,10 @@ import java.sql.JDBCType; import java.sql.SQLException; import java.time.Instant; +import java.time.OffsetDateTime; import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; import java.util.Arrays; import java.util.Collections; import java.util.List; @@ -170,4 +173,45 @@ public void testClickHouseToJavaClass() { assertEquals(JdbcUtils.convertToJavaClass(ClickHouseDataType.UInt128), BigInteger.class); assertEquals(JdbcUtils.convertToJavaClass(ClickHouseDataType.UInt256), BigInteger.class); } + + @Test(groups = {"unit"}, dataProvider = "zonedTimestampZones") + public void testConvertZonedDateTimeToTimestampPreservesInstant(String zoneId) throws SQLException { + Instant instant = Instant.parse("2026-03-12T14:58:08.123Z"); + ZonedDateTime zdt = instant.atZone(ZoneId.of(zoneId)); + + java.sql.Timestamp ts = (java.sql.Timestamp) JdbcUtils.convert(zdt, java.sql.Timestamp.class); + + assertEquals(ts.toInstant(), instant); + assertEquals(ts.getNanos(), zdt.getNano()); + } + + @Test(groups = {"unit"}) + public void testConvertOffsetDateTimeToTimestampPreservesInstant() throws SQLException { + Instant instant = Instant.parse("2026-03-12T14:58:08.456789123Z"); + OffsetDateTime odt = instant.atOffset(ZoneOffset.ofHours(9)); + + java.sql.Timestamp ts = (java.sql.Timestamp) JdbcUtils.convert(odt, java.sql.Timestamp.class); + + assertEquals(ts.toInstant(), instant); + assertEquals(ts.getNanos(), odt.getNano()); + } + + @Test(groups = {"unit"}) + public void testConvertInstantToTimestampPreservesInstant() throws SQLException { + Instant instant = Instant.parse("2026-03-12T14:58:08.001Z"); + + java.sql.Timestamp ts = (java.sql.Timestamp) JdbcUtils.convert(instant, java.sql.Timestamp.class); + + assertEquals(ts.toInstant(), instant); + } + + @DataProvider(name = "zonedTimestampZones") + public Object[][] zonedTimestampZones() { + return new Object[][] { + {"UTC"}, + {"Asia/Tokyo"}, + {"America/New_York"}, + {"Europe/Berlin"}, + }; + } }