Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions docs/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 11 additions & 1 deletion jdbc-v2/src/main/java/com/clickhouse/jdbc/ResultSetImpl.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -1119,7 +1120,16 @@ public Timestamp getTimestamp(int columnIndex, Calendar cal) throws SQLException
}
wasNull = false;

return DataTypeUtils.toSqlTimestamp(zdt.toLocalDateTime(), cal.getTimeZone());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getTime disagrees with getTimestamp

Medium Severity

getTimestamp now converts DateTime* values using the column zone so epoch millis match the wire instant, but getTime still takes the column wall-clock LocalDateTime and anchors it with the caller Calendar timezone. On the same row, getTimestamp and getTime can disagree whenever the column timezone and Calendar differ.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 77ee79b. Configure here.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@arimu1
This is very correct comment. Calendar used to reinterpret timestamp internal value to make it work according to spec.

// 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()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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());
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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"},
};
}
}