From 3df3317f517ec4e7fd1cc74b62b1266e4466407d Mon Sep 17 00:00:00 2001 From: Polyglot AI <293096396+polyglotAI-bot@users.noreply.github.com> Date: Mon, 15 Jun 2026 10:47:05 +0000 Subject: [PATCH 1/3] =?UTF-8?q?Fix=20ClickHouseColumn:=20resolve=20Fixed/U?= =?UTF-8?q?TC=C2=B1HH:MM:SS=20timezone=20names=20to=20correct=20offset?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TimeZone.getTimeZone(String) does not recognise the synthetic fixed-offset timezone names that ClickHouse emits in column type metadata — names of the form Fixed/UTC±HH:MM:SS (e.g. Fixed/UTC+05:30:00). The JDK silently falls back to GMT for any unrecognised zone ID, so DateTime columns declared with a literal offset stored UTC in ClickHouseColumn.timeZone, shifting every wall-clock value read from those columns by the declared offset. Introduce a package-private resolveTimeZone helper that detects the Fixed/UTC prefix, extracts the offset string, and delegates to ZoneOffset.of() + TimeZone.getTimeZone(ZoneId) — both available since Java 8. All four call sites in ClickHouseColumn.update() now use resolveTimeZone instead of TimeZone.getTimeZone directly. Fixes: https://github.com/ClickHouse/clickhouse-java/issues/2876 --- .../com/clickhouse/data/ClickHouseColumn.java | 40 ++++++++++-- .../clickhouse/data/ClickHouseColumnTest.java | 61 +++++++++++++++++++ 2 files changed, 95 insertions(+), 6 deletions(-) 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..0a38655d8 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 timezone name as emitted in ClickHouse column type metadata + * @return resolved {@link TimeZone}, never null + */ + 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..b347c55ba 100644 --- a/clickhouse-data/src/test/java/com/clickhouse/data/ClickHouseColumnTest.java +++ b/clickhouse-data/src/test/java/com/clickhouse/data/ClickHouseColumnTest.java @@ -5,6 +5,7 @@ import java.util.Collections; import java.util.LinkedList; import java.util.List; +import java.util.TimeZone; import org.testng.Assert; import org.testng.annotations.DataProvider; @@ -508,4 +509,64 @@ 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"); + + // 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"); + + // 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"); + } } From d6be12fd4a22de9dd1240a81ce91e4e1715be9d1 Mon Sep 17 00:00:00 2001 From: Polyglot AI <293096396+polyglotAI-bot@users.noreply.github.com> Date: Mon, 15 Jun 2026 12:54:00 +0000 Subject: [PATCH 2/3] Add tests for Fixed/UTC fallback and DateTime(scale, tz) timezone resolution Covers the two new-code lines flagged by SonarCloud on #2877: - resolveTimeZone DateTimeException fallback (out-of-range Fixed/UTC offset, +19:00:00, which exceeds +-18:00 and makes ZoneOffset.of throw) - the helper must degrade gracefully to GMT, not propagate the exception. - the DateTime size>=2 parse site, exercised via DateTime(3, 'Fixed/UTC+05:30:00'), which is handled like DateTime64 and was previously unreached by the test. Test-only change; no production code modified. --- .../com/clickhouse/data/ClickHouseColumnTest.java | 15 +++++++++++++++ 1 file changed, 15 insertions(+) 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 b347c55ba..5df9cf721 100644 --- a/clickhouse-data/src/test/java/com/clickhouse/data/ClickHouseColumnTest.java +++ b/clickhouse-data/src/test/java/com/clickhouse/data/ClickHouseColumnTest.java @@ -543,6 +543,13 @@ public void testResolveFixedUtcTimezone() { 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(), @@ -564,6 +571,14 @@ public void testResolveFixedUtcTimezone() { 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, From 7d480534edbcb5a595651c96554209de9dd1331c Mon Sep 17 00:00:00 2001 From: Polyglot AI <293096396+polyglotAI-bot@users.noreply.github.com> Date: Mon, 15 Jun 2026 13:13:11 +0000 Subject: [PATCH 3/3] Clarify resolveTimeZone null contract and drop unused test import Addresses Copilot review on PR #2877: - remove the unused java.util.TimeZone import from ClickHouseColumnTest (the test references resolveTimeZone(...)/getTimeZone() only, never the TimeZone type) - document resolveTimeZone's non-null tzName precondition; no behavior change (null is unreachable from all parse call sites, which pass a non-null replace("'","") result) Fixes: https://github.com/ClickHouse/clickhouse-java/issues/2876 --- .../src/main/java/com/clickhouse/data/ClickHouseColumn.java | 4 ++-- .../test/java/com/clickhouse/data/ClickHouseColumnTest.java | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) 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 0a38655d8..92da0473c 100644 --- a/clickhouse-data/src/main/java/com/clickhouse/data/ClickHouseColumn.java +++ b/clickhouse-data/src/main/java/com/clickhouse/data/ClickHouseColumn.java @@ -140,8 +140,8 @@ public enum DefaultValue { * converts the offset portion to a {@link ZoneOffset} before creating the * {@link TimeZone}, preserving the column's declared fixed offset. * - * @param tzName timezone name as emitted in ClickHouse column type metadata - * @return resolved {@link TimeZone}, never null + * @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")) { 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 5df9cf721..0e88ee04c 100644 --- a/clickhouse-data/src/test/java/com/clickhouse/data/ClickHouseColumnTest.java +++ b/clickhouse-data/src/test/java/com/clickhouse/data/ClickHouseColumnTest.java @@ -5,7 +5,6 @@ import java.util.Collections; import java.util.LinkedList; import java.util.List; -import java.util.TimeZone; import org.testng.Assert; import org.testng.annotations.DataProvider;