diff --git a/clickhouse-data/src/main/java/com/clickhouse/data/ClickHouseColumn.java b/clickhouse-data/src/main/java/com/clickhouse/data/ClickHouseColumn.java index 64ed857cc..92da0473c 100644 --- a/clickhouse-data/src/main/java/com/clickhouse/data/ClickHouseColumn.java +++ b/clickhouse-data/src/main/java/com/clickhouse/data/ClickHouseColumn.java @@ -37,7 +37,9 @@ import java.io.Serializable; import java.lang.reflect.Array; +import java.time.DateTimeException; import java.time.OffsetDateTime; +import java.time.ZoneOffset; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -127,6 +129,34 @@ public enum DefaultValue { private DefaultValue defaultValue; private String defaultExpression; + /** + * Resolves a timezone name to a {@link TimeZone}. + * + *
ClickHouse uses synthetic fixed-offset timezone names of the form + * {@code Fixed/UTC±HH:MM:SS} (e.g. {@code Fixed/UTC+05:30:00}) for columns + * declared with a literal offset. These names are not recognised by + * {@link TimeZone#getTimeZone(String)}, which silently falls back to GMT for + * any unrecognised ID. This method detects the {@code Fixed/UTC} prefix and + * converts the offset portion to a {@link ZoneOffset} before creating the + * {@link TimeZone}, preserving the column's declared fixed offset. + * + * @param tzName non-null timezone name as emitted in ClickHouse column type metadata + * @return resolved {@link TimeZone}; never null for a non-null {@code tzName} + */ + static TimeZone resolveTimeZone(String tzName) { + if (tzName != null && tzName.startsWith("Fixed/UTC")) { + String offset = tzName.substring("Fixed/UTC".length()); + if (!offset.isEmpty()) { + try { + return TimeZone.getTimeZone(ZoneOffset.of(offset)); + } catch (DateTimeException ignored) { + // fall through to standard resolution + } + } + } + return TimeZone.getTimeZone(tzName); + } + private static ClickHouseColumn update(ClickHouseColumn column) { column.enumConstants = ClickHouseEnum.EMPTY; int size = column.parameters.size(); @@ -204,15 +234,14 @@ private static ClickHouseColumn update(ClickHouseColumn column) { } column.template = ClickHouseOffsetDateTimeValue.ofNull( column.scale = Integer.parseInt(column.parameters.get(0)), - column.timeZone = TimeZone.getTimeZone(column.parameters.get(1).replace("'", ""))); + column.timeZone = resolveTimeZone(column.parameters.get(1).replace("'", ""))); } else if (size == 1) { // same as DateTime32 if (!column.nullable) { column.estimatedByteLength += ClickHouseDataType.DateTime32.getByteLength(); } column.template = ClickHouseOffsetDateTimeValue.ofNull( column.scale, - // unfortunately this will fall back to GMT if the time zone cannot be resolved - column.timeZone = TimeZone.getTimeZone(column.parameters.get(0).replace("'", ""))); + column.timeZone = resolveTimeZone(column.parameters.get(0).replace("'", ""))); } break; case DateTime32: @@ -220,8 +249,7 @@ private static ClickHouseColumn update(ClickHouseColumn column) { if (size > 0) { column.template = ClickHouseOffsetDateTimeValue.ofNull( column.scale, - // unfortunately this will fall back to GMT if the time zone cannot be resolved - column.timeZone = TimeZone.getTimeZone(column.parameters.get(0).replace("'", ""))); + column.timeZone = resolveTimeZone(column.parameters.get(0).replace("'", ""))); } break; case DateTime64: @@ -232,7 +260,7 @@ private static ClickHouseColumn update(ClickHouseColumn column) { if (size > 1) { column.template = ClickHouseOffsetDateTimeValue.ofNull( column.scale, - column.timeZone = TimeZone.getTimeZone(column.parameters.get(1).replace("'", ""))); + column.timeZone = resolveTimeZone(column.parameters.get(1).replace("'", ""))); } break; case Decimal: diff --git a/clickhouse-data/src/test/java/com/clickhouse/data/ClickHouseColumnTest.java b/clickhouse-data/src/test/java/com/clickhouse/data/ClickHouseColumnTest.java index 6a3508b11..0e88ee04c 100644 --- a/clickhouse-data/src/test/java/com/clickhouse/data/ClickHouseColumnTest.java +++ b/clickhouse-data/src/test/java/com/clickhouse/data/ClickHouseColumnTest.java @@ -508,4 +508,79 @@ public Object[][] testJSONBinaryFormat_dp() { {"JSON(max_dynamic_types=3,max_dynamic_paths=3, SKIP REGEXP '^-.*',SKIP ff, flags Array(Array(Array(Int8))), SKIP alt_count)", 2, Arrays.asList("flags")}, }; } + + /** + * Regression test for https://github.com/ClickHouse/clickhouse-java/issues/2876. + * + * ClickHouse emits synthetic fixed-offset timezone names of the form + * {@code Fixed/UTC±HH:MM:SS} (e.g. {@code Fixed/UTC+05:30:00}) in column + * type metadata. {@code TimeZone.getTimeZone} does not recognise this format + * and silently falls back to GMT, shifting every timestamp read from such a + * column by the declared offset. {@code resolveTimeZone} must convert the + * {@code Fixed/UTC} prefix to a proper offset zone. + */ + @Test(groups = {"unit"}) + public void testResolveFixedUtcTimezone() { + int plusFiveThirtyMs = (5 * 3600 + 30 * 60) * 1000; // +05:30 in milliseconds + + // Core cases: Fixed/UTC±HH:MM:SS names must yield the declared offset. + Assert.assertEquals(ClickHouseColumn.resolveTimeZone("Fixed/UTC+05:30:00").getRawOffset(), + plusFiveThirtyMs, + "Fixed/UTC+05:30:00 must resolve to +05:30, not GMT"); + Assert.assertEquals(ClickHouseColumn.resolveTimeZone("Fixed/UTC-08:00:00").getRawOffset(), + -(8 * 3600 * 1000), + "Fixed/UTC-08:00:00 must resolve to -08:00, not GMT"); + Assert.assertEquals(ClickHouseColumn.resolveTimeZone("Fixed/UTC+00:00:00").getRawOffset(), + 0, + "Fixed/UTC+00:00:00 must resolve to UTC (+00:00)"); + + // Contrast cases: IANA names and plain "UTC" must continue to work unchanged. + Assert.assertEquals(ClickHouseColumn.resolveTimeZone("Asia/Kolkata").getRawOffset(), + plusFiveThirtyMs, + "IANA Asia/Kolkata (+05:30) must still resolve correctly"); + Assert.assertEquals(ClickHouseColumn.resolveTimeZone("UTC").getRawOffset(), + 0, + "Plain UTC must still resolve to 0 offset"); + + // Defensive fallback: an out-of-range Fixed/UTC offset (beyond ±18:00) makes + // ZoneOffset.of throw DateTimeException; resolveTimeZone must degrade gracefully + // to TimeZone.getTimeZone (which yields GMT) rather than propagate the exception. + Assert.assertEquals(ClickHouseColumn.resolveTimeZone("Fixed/UTC+19:00:00").getRawOffset(), + 0, + "Out-of-range Fixed/UTC+19:00:00 must fall back to GMT, not throw"); + + // Column-level: DateTime('Fixed/UTC+05:30:00') uses the timezone as the sole parameter. + ClickHouseColumn dtCol = ClickHouseColumn.of("d", "DateTime('Fixed/UTC+05:30:00')"); + Assert.assertNotNull(dtCol.getTimeZone(), + "DateTime column timezone must not be null"); + Assert.assertEquals(dtCol.getTimeZone().getRawOffset(), plusFiveThirtyMs, + "DateTime('Fixed/UTC+05:30:00') column timezone must be +05:30"); + + // DateTime64(3, 'Fixed/UTC+05:30:00') uses the timezone as the second parameter. + ClickHouseColumn dt64Col = ClickHouseColumn.of("d", "DateTime64(3, 'Fixed/UTC+05:30:00')"); + Assert.assertNotNull(dt64Col.getTimeZone(), + "DateTime64 column timezone must not be null"); + Assert.assertEquals(dt64Col.getTimeZone().getRawOffset(), plusFiveThirtyMs, + "DateTime64(3, 'Fixed/UTC+05:30:00') column timezone must be +05:30"); + + // DateTime32('Fixed/UTC+05:30:00') - separate code path under DateTime32/Time. + ClickHouseColumn dt32Col = ClickHouseColumn.of("d", "DateTime32('Fixed/UTC+05:30:00')"); + Assert.assertNotNull(dt32Col.getTimeZone(), + "DateTime32 column timezone must not be null"); + Assert.assertEquals(dt32Col.getTimeZone().getRawOffset(), plusFiveThirtyMs, + "DateTime32('Fixed/UTC+05:30:00') column timezone must be +05:30"); + + // DateTime with an explicit scale routes through the size>=2 arm (handled like + // DateTime64), where the timezone is the second parameter. + ClickHouseColumn dtScaleCol = ClickHouseColumn.of("d", "DateTime(3, 'Fixed/UTC+05:30:00')"); + Assert.assertNotNull(dtScaleCol.getTimeZone(), + "DateTime(scale, tz) column timezone must not be null"); + Assert.assertEquals(dtScaleCol.getTimeZone().getRawOffset(), plusFiveThirtyMs, + "DateTime(3, 'Fixed/UTC+05:30:00') column timezone must be +05:30"); + + // Contrast: IANA-named DateTime column must be unchanged by the fix. + ClickHouseColumn ianaCol = ClickHouseColumn.of("d", "DateTime('Asia/Kolkata')"); + Assert.assertEquals(ianaCol.getTimeZone().getRawOffset(), plusFiveThirtyMs, + "DateTime('Asia/Kolkata') column timezone must remain +05:30"); + } }