Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -127,6 +129,34 @@
private DefaultValue defaultValue;
private String defaultExpression;

/**
* Resolves a timezone name to a {@link TimeZone}.
*
* <p>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);
}
Comment on lines +146 to +158

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Thanks — addressed the documentation part in 7d48053 by taking your "update the Javadoc" option: tzName is now documented as non-null and the return is qualified as "never null for a non-null tzName".

I verified the underlying claim empirically (JDK 17): TimeZone.getTimeZone((String) null) does throw NullPointerException. However, I deliberately did not add a runtime null guard, because:

  • Null is unreachable on every call path. All four call sites pass column.parameters.get(i).replace("'", ""). String.replace returns non-null, and a null list element would already NPE at .replace(...) before resolveTimeZone is entered — so the method is never invoked with null.
  • No new/reachable NPE is introduced. The pre-existing code called TimeZone.getTimeZone(parameters.get(i).replace("'", "")) directly and had identical null behavior; this PR only inserts the Fixed/UTC branch ahead of that same delegating call.
  • A defensive null→default guard would change behavior on an unreachable path and risks masking a genuine upstream parsing bug, which docs/changes_checklist.md → "Null handling changed" cautions against ("New null checks do not hide genuine bugs").

So the clarified Javadoc makes the existing non-null contract explicit without altering behavior. Happy to add Objects.requireNonNull(tzName) instead if maintainers would prefer an explicit precondition check.

Generated by Nerve


private static ClickHouseColumn update(ClickHouseColumn column) {
column.enumConstants = ClickHouseEnum.EMPTY;
int size = column.parameters.size();
Expand Down Expand Up @@ -204,24 +234,22 @@
}
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("'", "")));

Check warning on line 237 in clickhouse-data/src/main/java/com/clickhouse/data/ClickHouseColumn.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract the assignment out of this expression.

See more on https://sonarcloud.io/project/issues?id=ClickHouse_clickhouse-java&issues=AZ7K8QmK5CRmXF4HyfU4&open=AZ7K8QmK5CRmXF4HyfU4&pullRequest=2877
} 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("'", "")));

Check warning on line 244 in clickhouse-data/src/main/java/com/clickhouse/data/ClickHouseColumn.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract the assignment out of this expression.

See more on https://sonarcloud.io/project/issues?id=ClickHouse_clickhouse-java&issues=AZ7K8QmK5CRmXF4HyfU5&open=AZ7K8QmK5CRmXF4HyfU5&pullRequest=2877
}
break;
case DateTime32:
case Time:
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("'", "")));

Check warning on line 252 in clickhouse-data/src/main/java/com/clickhouse/data/ClickHouseColumn.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract the assignment out of this expression.

See more on https://sonarcloud.io/project/issues?id=ClickHouse_clickhouse-java&issues=AZ7K8QmK5CRmXF4HyfU6&open=AZ7K8QmK5CRmXF4HyfU6&pullRequest=2877
}
break;
case DateTime64:
Expand All @@ -232,7 +260,7 @@
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("'", "")));

Check warning on line 263 in clickhouse-data/src/main/java/com/clickhouse/data/ClickHouseColumn.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract the assignment out of this expression.

See more on https://sonarcloud.io/project/issues?id=ClickHouse_clickhouse-java&issues=AZ7K8QmK5CRmXF4HyfU7&open=AZ7K8QmK5CRmXF4HyfU7&pullRequest=2877
}
break;
case Decimal:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
}
Loading