Skip to content
Merged
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,10 @@ of `NULL` was not set and read. (https://github.com/ClickHouse/clickhouse-java/i

- **[jdbc-v2, client-v2]** Fixed writing nullable marker for nested `Tuple` and `Map values. (https://github.com/ClickHouse/clickhouse-java/issues/2721)

- **[client-v2]** Fixed `DateTime`/`DateTime64` columns declared with a synthetic fixed-offset timezone name
(`Fixed/UTC±HH:MM:SS`, e.g. `Fixed/UTC+05:30:00`) being silently read in UTC instead of the declared offset. The
`RowBinary` reader now recovers the offset from the column's declared type. (https://github.com/ClickHouse/clickhouse-java/issues/2876)

## 0.9.8

### Improvements
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,13 @@
import java.net.Inet4Address;
import java.net.Inet6Address;
import java.nio.charset.StandardCharsets;
import java.time.DateTimeException;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.Period;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.time.temporal.TemporalAmount;
import java.util.ArrayList;
Expand Down Expand Up @@ -115,7 +117,6 @@
ClickHouseDataType dataType = actualColumn.getDataType();
int precision = actualColumn.getPrecision();
int scale = actualColumn.getScale();
TimeZone timezone = actualColumn.getTimeZoneOrDefault(timeZone);

try {
switch (dataType) {
Expand Down Expand Up @@ -184,11 +185,10 @@
case Date32:
return (T) readDate32AsLocalDate();
case DateTime:
return convertDateTime(readDateTime32(timezone), typeHint);
case DateTime32:
return convertDateTime(readDateTime32(timezone), typeHint);
return convertDateTime(readDateTime32(resolveTimeZone(actualColumn)), typeHint);
case DateTime64:
return convertDateTime(readDateTime64(scale, timezone), typeHint);
return convertDateTime(readDateTime64(scale, resolveTimeZone(actualColumn)), typeHint);
case Time:
return (T) readTime();
case Time64:
Expand Down Expand Up @@ -1036,6 +1036,44 @@
return d.atStartOfDay(tz.toZoneId()).withZoneSameInstant(tz.toZoneId());
}

private static final String FIXED_UTC_PREFIX = "Fixed/UTC";

/**
* Resolves the timezone declared by a {@code DateTime}/{@code DateTime32}/{@code DateTime64}
* column, honouring ClickHouse's synthetic fixed-offset timezone names.
*
* <p>For a column declared with a literal offset, ClickHouse emits a synthetic timezone name of
* the form {@code Fixed/UTC±HH:MM:SS} (e.g. {@code Fixed/UTC+05:30:00}) in the column type
* metadata. {@link TimeZone#getTimeZone(String)} does not recognise these ids and silently
* returns GMT, which drops the declared offset and shifts every value read from the column by
* that offset. This recovers the offset from the declared name via {@link ZoneOffset}; ordinary
* (IANA) timezone names, and columns without a declared timezone, are resolved as before.
*
* @param column the {@code DateTime}/{@code DateTime64} column being read
* @return the timezone used to interpret the column's epoch value; never {@code null}
*/
private TimeZone resolveTimeZone(ClickHouseColumn column) {

Check failure on line 1055 in client-v2/src/main/java/com/clickhouse/client/api/data_formats/internal/BinaryStreamReader.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to reduce its Cognitive Complexity from 16 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=ClickHouse_clickhouse-java&issues=AZ9DqPTYWG6ZX0UL7Vft&open=AZ9DqPTYWG6ZX0UL7Vft&pullRequest=2877
for (String param : column.getParameters()) {
// The timezone is the sole quoted (non-numeric) parameter of a DateTime type; the
// server may emit it quoted ('Fixed/UTC+05:30:00') or bare (in dynamic subcolumns).
int start = !param.isEmpty() && param.charAt(0) == '\'' ? 1 : 0;
if (param.startsWith(FIXED_UTC_PREFIX, start)) {
int end = param.endsWith("'") ? param.length() - 1 : param.length();
String offset = param.substring(start + FIXED_UTC_PREFIX.length(), end);
if (!offset.isEmpty()) {
try {
return TimeZone.getTimeZone(ZoneOffset.of(offset));
} catch (DateTimeException ignored) {
// Offset out of range (beyond ±18:00) or malformed: fall back to the
// column's default resolution below instead of failing the read.
}
}
break;
}
}
return column.getTimeZoneOrDefault(timeZone);
}

private ZonedDateTime readDateTime32(TimeZone tz) throws IOException {
return readDateTime32(input, bufferAllocator.allocate(INT32_SIZE), tz);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1054,6 +1054,64 @@
Assert.assertEquals(record.getDuration("time"), Duration.ofHours(999).plusMinutes(59).plusSeconds(59).negated());
}

/**
* Regression test for <a href="https://github.com/ClickHouse/clickhouse-java/issues/2876">#2876</a>.
*
* <p>ClickHouse emits synthetic fixed-offset timezone names of the form {@code Fixed/UTC±HH:MM:SS}
* for {@code DateTime}/{@code DateTime64} columns declared with a literal offset. These names are
* not recognised by {@link java.util.TimeZone#getTimeZone(String)}, which silently returns GMT, so
* before the fix the wall-clock of every value read from such a column was shifted to UTC (e.g. by
* 5h30m for {@code +05:30}). The reader must interpret the epoch value in the declared fixed offset,
* exactly as it already does for IANA names such as {@code Asia/Kolkata}.
*/
@Test(groups = {"integration"})
public void testDateTimeWithFixedOffsetTimeZone() throws Exception {

Check warning on line 1068 in client-v2/src/test/java/com/clickhouse/client/datatypes/DataTypeTests.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove the declaration of thrown exception 'java.lang.Exception', as it cannot be thrown from method's body.

See more on https://sonarcloud.io/project/issues?id=ClickHouse_clickhouse-java&issues=AZ9DqPN8WG6ZX0UL7Vfo&open=AZ9DqPN8WG6ZX0UL7Vfo&pullRequest=2877
// For every column below the server wall-clock is 2024-01-15 10:30:00 in the *declared* zone.
List<GenericRecord> records = client.queryAll(
"SELECT toDateTime('2024-01-15 10:30:00', 'Fixed/UTC+05:30:00') AS dt_plus, "
+ "toDateTime('2024-01-15 10:30:00', 'Fixed/UTC-08:00:00') AS dt_minus, "
+ "toDateTime64('2024-01-15 10:30:00.123', 3, 'Fixed/UTC+05:30:00') AS dt64_plus, "
+ "toDateTime64('2024-01-15 10:30:00.123', 3, 'Fixed/UTC-08:00:00') AS dt64_minus, "
+ "toDateTime('2024-01-15 10:30:00', 'Asia/Kolkata') AS dt_iana, "
+ "toDateTime('2024-01-15 10:30:00', 'UTC') AS dt_utc");
Assert.assertEquals(records.size(), 1);
GenericRecord row = records.get(0);

final LocalDateTime wallClock = LocalDateTime.of(2024, 1, 15, 10, 30, 0);

Check warning on line 1080 in client-v2/src/test/java/com/clickhouse/client/datatypes/DataTypeTests.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use a "java.time.Month" enum constant instead of this int literal.

See more on https://sonarcloud.io/project/issues?id=ClickHouse_clickhouse-java&issues=AZ9DqPN8WG6ZX0UL7Vfp&open=AZ9DqPN8WG6ZX0UL7Vfp&pullRequest=2877

// DateTime, +05:30 fixed offset: wall-clock preserved and offset applied (was 05:00 at +00:00).
Assert.assertEquals(row.getZonedDateTime("dt_plus").toLocalDateTime(), wallClock);
Assert.assertEquals(row.getZonedDateTime("dt_plus").getOffset(), ZoneOffset.of("+05:30"));
Assert.assertEquals(row.getInstant("dt_plus"), Instant.ofEpochSecond(1705294800L));

// DateTime, -08:00 fixed offset: exercises a west-of-UTC (negative) offset.
Assert.assertEquals(row.getZonedDateTime("dt_minus").toLocalDateTime(), wallClock);
Assert.assertEquals(row.getZonedDateTime("dt_minus").getOffset(), ZoneOffset.of("-08:00"));
Assert.assertEquals(row.getInstant("dt_minus"), Instant.ofEpochSecond(1705343400L));

// DateTime64(3), +05:30 fixed offset: fractional seconds preserved alongside the offset.
Assert.assertEquals(row.getZonedDateTime("dt64_plus").toLocalDateTime(), wallClock.withNano(123_000_000));
Assert.assertEquals(row.getZonedDateTime("dt64_plus").getOffset(), ZoneOffset.of("+05:30"));
Assert.assertEquals(row.getInstant("dt64_plus"), Instant.ofEpochSecond(1705294800L, 123_000_000L));

// DateTime64(3), -08:00 fixed offset: negative offset combined with fractional seconds.
Assert.assertEquals(row.getZonedDateTime("dt64_minus").toLocalDateTime(), wallClock.withNano(123_000_000));
Assert.assertEquals(row.getZonedDateTime("dt64_minus").getOffset(), ZoneOffset.of("-08:00"));
Assert.assertEquals(row.getInstant("dt64_minus"), Instant.ofEpochSecond(1705343400L, 123_000_000L));

// Contrast 1 — IANA Asia/Kolkata (also +05:30) is unchanged by the fix and must render
// identically to the Fixed/UTC+05:30:00 column (same wall-clock and same instant).
Assert.assertEquals(row.getZonedDateTime("dt_iana").toLocalDateTime(), wallClock);
Assert.assertEquals(row.getZonedDateTime("dt_iana").getOffset(), ZoneOffset.of("+05:30"));
Assert.assertEquals(row.getInstant("dt_iana"), row.getInstant("dt_plus"));

// Contrast 2 — a plain UTC column keeps a +00:00 offset (the fix is offset-specific, not a
// blanket shift): identical wall-clock text, but a different instant from the +05:30 columns.
Assert.assertEquals(row.getZonedDateTime("dt_utc").toLocalDateTime(), wallClock);
Assert.assertEquals(row.getZonedDateTime("dt_utc").getOffset(), ZoneOffset.UTC);
Assert.assertEquals(row.getInstant("dt_utc"), Instant.ofEpochSecond(1705314600L));
}

@Test(groups = {"integration"}, dataProvider = "testTimeData")
public void testTime(String column, String value, LocalDateTime expectedDt) throws Exception {
if (isVersionMatch("(,25.5]")) {
Expand Down
Loading