You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@calcite.apache.org by mb...@apache.org on 2026/09/08 07:03:34 UTC

(calcite-avatica) 01/01: [CALCITE-7494] Avatica conversion to string of TIMESTAMP WITH TIME ZONE does not include time zone

This is an automated email from the ASF dual-hosted git repository.

mihaibudiu pushed a commit to branch issue7494
in repository https://gitbox.apache.org/repos/asf/calcite-avatica.git

commit c2ae6233184a4f9f0df11fa15b9e5de320447a4d
Author: Mihai Budiu <mb...@feldera.com>
AuthorDate: Tue Aug 4 11:28:18 2026 -0700

    [CALCITE-7494] Avatica conversion to string of TIMESTAMP WITH TIME ZONE does not include time zone
    
    Signed-off-by: Mihai Budiu <mb...@feldera.com>
---
 .../calcite/avatica/util/AbstractCursor.java       |  36 +++++
 .../util/TimestampTzFromNumberAccessorTest.java    | 152 +++++++++++++++++++++
 2 files changed, 188 insertions(+)

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.
+   *
+   * <p>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}
+     *
+     * <p>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;
+
+/**
+ * <p>Test cases for
+ * <a href="https://issues.apache.org/jira/browse/CALCITE-7494">[CALCITE-7494]
+ * Avatica conversion to string of TIMESTAMP WITH TIME ZONE does not include
+ * time zone</a>.
+ */
+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.<Object>singletonList(0L)).iterator());
+    final List<Cursor.Accessor> 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;
+    }
+  }
+}