From bc78cdd75d03752888779ca776b8dde077e3c5e8 Mon Sep 17 00:00:00 2001 From: Mihai Budiu Date: Tue, 4 Aug 2026 11:28:18 -0700 Subject: [PATCH] [CALCITE-7494] Avatica conversion to string of TIMESTAMP WITH TIME ZONE does not include time zone Signed-off-by: Mihai Budiu --- .../calcite/avatica/util/AbstractCursor.java | 36 +++++ .../TimestampTzFromNumberAccessorTest.java | 152 ++++++++++++++++++ 2 files changed, 188 insertions(+) create mode 100644 core/src/test/java/org/apache/calcite/avatica/util/TimestampTzFromNumberAccessorTest.java diff --git a/core/src/main/java/org/apache/calcite/avatica/util/AbstractCursor.java b/core/src/main/java/org/apache/calcite/avatica/util/AbstractCursor.java index e63ac7172..47ffe8c67 100644 --- a/core/src/main/java/org/apache/calcite/avatica/util/AbstractCursor.java +++ b/core/src/main/java/org/apache/calcite/avatica/util/AbstractCursor.java @@ -170,6 +170,10 @@ protected Accessor createAccessor(ColumnMetaData columnMetaData, case PRIMITIVE_LONG: case LONG: case NUMBER: + if (isTimestampTz(columnMetaData)) { + return new TimestampTzFromNumberAccessor(getter, localCalendar, + columnMetaData.precision); + } return new TimestampFromNumberAccessor(getter, localCalendar, columnMetaData.precision); case JAVA_SQL_TIMESTAMP: return new TimestampAccessor(getter, localCalendar, columnMetaData.precision); @@ -239,6 +243,17 @@ protected Accessor createAccessor(ColumnMetaData columnMetaData, } } + /** Returns whether a column contains TIMESTAMP WITH TIME ZONE values. + * + *

Such columns share the JDBC type {@link Types#TIMESTAMP} with plain + * TIMESTAMP columns; only the type name distinguishes them. */ + private static boolean isTimestampTz(ColumnMetaData columnMetaData) { + final String typeName = columnMetaData.type.getName(); + return typeName != null + && (typeName.startsWith("TIMESTAMP_TZ") + || typeName.startsWith("TIMESTAMP WITH TIME ZONE")); + } + protected abstract Getter createGetter(int ordinal); public abstract boolean next(); @@ -1183,6 +1198,27 @@ protected Number getNumber() throws SQLException { } } + /** + * Accessor that assumes that the underlying value is a TIMESTAMP WITH TIME + * ZONE, in its default representation {@code long}: milliseconds since the + * UNIX epoch in UTC; corresponds to a {@link java.sql.Types#TIMESTAMP} + * column whose type name is {@code TIMESTAMP_TZ}. + */ + static class TimestampTzFromNumberAccessor extends TimestampFromNumberAccessor { + TimestampTzFromNumberAccessor(Getter getter, Calendar localCalendar, int precision) { + super(getter, localCalendar, precision); + } + + /** {@inheritDoc} + * + *

The value is rendered in UTC and includes the time zone, + * e.g. "1970-01-01 00:00:00 UTC". */ + @Override public String getString() throws SQLException { + final String s = super.getString(); + return s == null ? null : s + " " + DateTimeUtils.UTC_ZONE.getID(); + } + } + /** * Accessor that assumes that the underlying value is a DATE, * represented as a java.sql.Date; diff --git a/core/src/test/java/org/apache/calcite/avatica/util/TimestampTzFromNumberAccessorTest.java b/core/src/test/java/org/apache/calcite/avatica/util/TimestampTzFromNumberAccessorTest.java new file mode 100644 index 000000000..90900e538 --- /dev/null +++ b/core/src/test/java/org/apache/calcite/avatica/util/TimestampTzFromNumberAccessorTest.java @@ -0,0 +1,152 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.calcite.avatica.util; + +import org.apache.calcite.avatica.ColumnMetaData; + +import org.junit.Before; +import org.junit.Test; + +import java.sql.SQLException; +import java.sql.Timestamp; +import java.sql.Types; +import java.util.Calendar; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.TimeZone; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.nullValue; +import static org.hamcrest.MatcherAssert.assertThat; + +/** + *

Test cases for + * [CALCITE-7494] + * Avatica conversion to string of TIMESTAMP WITH TIME ZONE does not include + * time zone. + */ +public class TimestampTzFromNumberAccessorTest { + // UTC: 2014-09-30 15:28:27.356 + private static final long DST_INSTANT = 1412090907356L; + + private Cursor.Accessor instance; + private Calendar localCalendar; + private Object value; + + /** + * Setup test environment by creating a + * {@link AbstractCursor.TimestampTzFromNumberAccessor} that reads from the + * instance variable {@code value}. + */ + @Before public void before() { + final AbstractCursor.Getter getter = new LocalGetter(); + localCalendar = Calendar.getInstance(TimeZone.getDefault(), Locale.ROOT); + instance = + new AbstractCursor.TimestampTzFromNumberAccessor(getter, localCalendar, 0); + } + + /** + * Test {@code getString()} includes the time zone. + */ + @Test public void testString() throws SQLException { + value = 0L; + assertThat(instance.getString(), is("1970-01-01 00:00:00 UTC")); + + value = DST_INSTANT; + assertThat(instance.getString(), is("2014-09-30 15:28:27 UTC")); + } + + /** + * Test {@code getString()} keeps the fractional seconds allowed by the + * column precision; fractional seconds beyond the precision are truncated, + * not rounded. + */ + @Test public void testStringWithPrecision() throws SQLException { + value = DST_INSTANT; + assertThat(stringWithPrecision(3), is("2014-09-30 15:28:27.356 UTC")); + assertThat(stringWithPrecision(2), is("2014-09-30 15:28:27.35 UTC")); + assertThat(stringWithPrecision(1), is("2014-09-30 15:28:27.3 UTC")); + assertThat(stringWithPrecision(0), is("2014-09-30 15:28:27 UTC")); + } + + /** Renders {@code value} via an accessor with the given column precision. */ + private String stringWithPrecision(int precision) throws SQLException { + return new AbstractCursor.TimestampTzFromNumberAccessor(new LocalGetter(), + null, precision).getString(); + } + + /** + * Test {@code getString()} returns null for a null value. + */ + @Test public void testStringNull() throws SQLException { + value = null; + assertThat(instance.getString(), nullValue()); + } + + /** + * Test {@code getTimestamp()} is not affected by the time zone suffix. + */ + @Test public void testTimestamp() throws SQLException { + value = DateTimeUtils.timestampStringToUnixDate("2014-09-30 15:28:27.356"); + assertThat(instance.getTimestamp(localCalendar), + is(Timestamp.valueOf("2014-09-30 15:28:27.356"))); + } + + /** + * Test a column whose JDBC type is {@link Types#TIMESTAMP} but whose type + * name is {@code TIMESTAMP_TZ} gets an accessor that includes the time zone + * in {@code getString()}, while a plain TIMESTAMP column does not. + */ + @Test public void testAccessorSelectionByTypeName() throws SQLException { + assertThat(getStringForColumn("TIMESTAMP_TZ"), + is("1970-01-01 00:00:00 UTC")); + assertThat(getStringForColumn("TIMESTAMP"), + is("1970-01-01 00:00:00")); + } + + private static String getStringForColumn(String typeName) throws SQLException { + final ColumnMetaData metaData = + ColumnMetaData.dummy( + ColumnMetaData.scalar(Types.TIMESTAMP, typeName, + ColumnMetaData.Rep.LONG), + true); + final ListIteratorCursor cursor = + new ListIteratorCursor( + Collections.singletonList( + Collections.singletonList(0L)).iterator()); + final List accessors = + cursor.createAccessors(Collections.singletonList(metaData), + Calendar.getInstance(TimeZone.getTimeZone("UTC"), Locale.ROOT), + null); + assertThat(cursor.next(), is(true)); + return accessors.get(0).getString(); + } + + /** + * Returns the value from the test instance to the accessor. + */ + private class LocalGetter implements AbstractCursor.Getter { + @Override public Object getObject() { + return value; + } + + @Override public boolean wasNull() { + return value == null; + } + } +}