Repository: apache/calcite
Pull request #4363: [CALCITE-7001] Cast of malformed literal to TIMESTAMP WITH LOCAL TIME  ZONE need to throw informative error
Author: login: zstan
Created: 2025-05-07T10:08:52Z | Merged: 2025-05-14T19:32:01Z

honestly, this PR looks like a bit hack as for me, but we can start with it and simplify all related stuff in future


Diff:
diff --git a/core/src/main/java/org/apache/calcite/rex/RexBuilder.java b/core/src/main/java/org/apache/calcite/rex/RexBuilder.java
index 19036f75b272..894e304ece1e 100644
--- a/core/src/main/java/org/apache/calcite/rex/RexBuilder.java
+++ b/core/src/main/java/org/apache/calcite/rex/RexBuilder.java
@@ -1976,9 +1976,9 @@ private static Comparable zeroValue(RelDataType type) {
     case TIME_TZ:
       return new TimeWithTimeZoneString(0, 0, 0, "GMT+00");
     case TIMESTAMP_WITH_LOCAL_TIME_ZONE:
-      return new TimestampString(0, 1, 1, 0, 0, 0);
+      return new TimestampString(1, 1, 1, 0, 0, 0);
     case TIMESTAMP_TZ:
-      return new TimestampWithTimeZoneString(0, 1, 1, 0, 0, 0, "GMT+00");
+      return new TimestampWithTimeZoneString(1, 1, 1, 0, 0, 0, "GMT+00");
     default:
       throw Util.unexpected(type.getSqlTypeName());
     }
diff --git a/core/src/main/java/org/apache/calcite/util/TimestampWithTimeZoneString.java b/core/src/main/java/org/apache/calcite/util/TimestampWithTimeZoneString.java
index e3c08bb7f19e..14195469ab96 100644
--- a/core/src/main/java/org/apache/calcite/util/TimestampWithTimeZoneString.java
+++ b/core/src/main/java/org/apache/calcite/util/TimestampWithTimeZoneString.java
@@ -22,12 +22,17 @@
 
 import java.text.SimpleDateFormat;
 import java.util.Calendar;
-import java.util.Locale;
 import java.util.TimeZone;
 
 import static com.google.common.base.Preconditions.checkArgument;
 
+import static org.apache.calcite.avatica.util.DateTimeUtils.DATE_FORMAT_STRING;
+import static org.apache.calcite.avatica.util.DateTimeUtils.TIMESTAMP_FORMAT_STRING;
+import static org.apache.calcite.util.DateTimeStringUtils.getDateFormatter;
+import static org.apache.calcite.util.Static.RESOURCE;
+
 import static java.lang.Math.floorMod;
+import static java.util.Objects.requireNonNull;
 
 /**
  * Timestamp with time-zone literal.
@@ -41,21 +46,39 @@ public class TimestampWithTimeZoneString
   final TimestampString localDateTime;
   final TimeZone timeZone;
   final String v;
+  private final DateTimeUtils.PrecisionTime pt;
+
+  private static final ThreadLocal<@Nullable SimpleDateFormat> TIMESTAMP_FORMAT =
+      ThreadLocal.withInitial(() ->
+          getDateFormatter(TIMESTAMP_FORMAT_STRING));
 
   /** Creates a TimestampWithTimeZoneString. */
   public TimestampWithTimeZoneString(TimestampString localDateTime, TimeZone timeZone) {
     this.localDateTime = localDateTime;
     this.timeZone = timeZone;
     this.v = localDateTime.toString() + " " + timeZone.getID();
+
+    this.pt = parseDateTime(localDateTime.toString(), this.timeZone);
   }
 
   /** Creates a TimestampWithTimeZoneString. */
   public TimestampWithTimeZoneString(String v) {
-    this.localDateTime = new TimestampString(v.substring(0, v.indexOf(' ', 11)));
-    String timeZoneString = v.substring(v.indexOf(' ', 11) + 1);
+    // search next space after "yyyy-MM-dd "
+    int pos = v.indexOf(' ', DATE_FORMAT_STRING.length() + 1);
+
+    if (pos == -1) {
+      throw RESOURCE.illegalLiteral("TIMESTAMP WITH LOCAL TIME ZONE", v,
+              RESOURCE.badFormat(TIMESTAMP_FORMAT_STRING).str()).ex();
+    }
+
+    String tsStr = v.substring(0, pos);
+    this.localDateTime = new TimestampString(tsStr);
+
+    String timeZoneString = v.substring(v.indexOf(' ', DATE_FORMAT_STRING.length() + 1) + 1);
     checkArgument(DateTimeStringUtils.isValidTimeZone(timeZoneString));
     this.timeZone = TimeZone.getTimeZone(timeZoneString);
     this.v = v;
+    this.pt = parseDateTime(tsStr, this.timeZone);
   }
 
   /** Creates a TimestampWithTimeZoneString for year, month, day, hour, minute,
@@ -110,23 +133,10 @@ public TimestampWithTimeZoneString withTimeZone(TimeZone timeZone) {
     if (this.timeZone.equals(timeZone)) {
       return this;
     }
-    String localDateTimeString = localDateTime.toString();
-    String v;
-    String fraction;
-    int i = localDateTimeString.indexOf('.');
-    if (i >= 0) {
-      v = localDateTimeString.substring(0, i);
-      fraction = localDateTimeString.substring(i + 1);
-    } else {
-      v = localDateTimeString;
-      fraction = null;
-    }
-    final DateTimeUtils.PrecisionTime pt =
-        DateTimeUtils.parsePrecisionDateTimeLiteral(v,
-            new SimpleDateFormat(DateTimeUtils.TIMESTAMP_FORMAT_STRING, Locale.ROOT),
-            this.timeZone, -1);
+    String fraction = pt.getFraction();
+
     pt.getCalendar().setTimeZone(timeZone);
-    if (fraction != null) {
+    if (!fraction.isEmpty()) {
       return new TimestampWithTimeZoneString(
           pt.getCalendar().get(Calendar.YEAR),
           pt.getCalendar().get(Calendar.MONTH) + 1,
@@ -202,4 +212,17 @@ public TimestampString getLocalTimestampString() {
   public TimeZone getTimeZone() {
     return timeZone;
   }
+
+  private static DateTimeUtils.PrecisionTime parseDateTime(String tsStr, TimeZone timeZone) {
+    DateTimeUtils.PrecisionTime pt =
+        DateTimeUtils.parsePrecisionDateTimeLiteral(tsStr, requireNonNull(TIMESTAMP_FORMAT.get()),
+            timeZone, -1);
+
+    if (pt == null) {
+      throw RESOURCE.illegalLiteral("TIMESTAMP WITH LOCAL TIME ZONE", tsStr,
+          RESOURCE.badFormat(TIMESTAMP_FORMAT_STRING).str()).ex();
+    }
+
+    return pt;
+  }
 }
diff --git a/core/src/test/java/org/apache/calcite/rex/RexBuilderTest.java b/core/src/test/java/org/apache/calcite/rex/RexBuilderTest.java
index 76e35c18ed14..4b714e8632d2 100644
--- a/core/src/test/java/org/apache/calcite/rex/RexBuilderTest.java
+++ b/core/src/test/java/org/apache/calcite/rex/RexBuilderTest.java
@@ -1121,11 +1121,11 @@ private static Stream<Arguments> testData4testMakeZeroLiteral() {
         type2rexLiteral.apply(typeFactory.createSqlType(SqlTypeName.TIME_WITH_LOCAL_TIME_ZONE),
             relDataType -> new TimeString(0, 0, 0)),
         type2rexLiteral.apply(typeFactory.createSqlType(SqlTypeName.TIMESTAMP_WITH_LOCAL_TIME_ZONE),
-            relDataType -> new TimestampString(0, 1, 1, 0, 0, 0)),
+            relDataType -> new TimestampString(1, 1, 1, 0, 0, 0)),
         type2rexLiteral.apply(typeFactory.createSqlType(SqlTypeName.TIME_TZ),
             relDataType -> new TimeWithTimeZoneString(0, 0, 0, "GMT+00:00")),
         type2rexLiteral.apply(typeFactory.createSqlType(SqlTypeName.TIMESTAMP_TZ),
-            relDataType -> new TimestampWithTimeZoneString(0, 1, 1, 0, 0, 0, "GMT+00:00")));
+            relDataType -> new TimestampWithTimeZoneString(1, 1, 1, 0, 0, 0, "GMT+00:00")));
   }
 
   /** Test case for
diff --git a/core/src/test/java/org/apache/calcite/test/SqlFunctionsTest.java b/core/src/test/java/org/apache/calcite/test/SqlFunctionsTest.java
index d68d4b2988a5..d1f8cb932236 100644
--- a/core/src/test/java/org/apache/calcite/test/SqlFunctionsTest.java
+++ b/core/src/test/java/org/apache/calcite/test/SqlFunctionsTest.java
@@ -74,10 +74,12 @@
 import static org.apache.calcite.runtime.SqlFunctions.toIntOptional;
 import static org.apache.calcite.runtime.SqlFunctions.toLong;
 import static org.apache.calcite.runtime.SqlFunctions.toLongOptional;
+import static org.apache.calcite.runtime.SqlFunctions.toTimestampWithLocalTimeZone;
 import static org.apache.calcite.runtime.SqlFunctions.trim;
 import static org.apache.calcite.runtime.SqlFunctions.upper;
 import static org.apache.calcite.test.Matchers.isListOf;
 
+import static org.hamcrest.CoreMatchers.containsString;
 import static org.hamcrest.CoreMatchers.equalTo;
 import static org.hamcrest.CoreMatchers.is;
 import static org.hamcrest.CoreMatchers.nullValue;
@@ -1836,6 +1838,44 @@ private void thereAndBack(byte[] bytes) {
     assertThat(toLongOptional(null), is(nullValue()));
   }
 
+  /**
+   * Test date after 0001-01-01 required by ANSI SQL - is passed.
+   * Test date before 0001-01-01 and malformed date time literal - is failed.
+   */
+  @Test void testToTimestampWithLocalTimeZone() {
+    Long ret = toTimestampWithLocalTimeZone("1970-01-01 00:00:01", TimeZone.getTimeZone("UTC"));
+    assertThat(ret, is(1000L));
+
+    ret = toTimestampWithLocalTimeZone("1970-01-01 00:00:01.010", TimeZone.getTimeZone("UTC"));
+    assertThat(ret, is(1010L));
+
+    ret = toTimestampWithLocalTimeZone("1970-01-01 00:00:01 "
+        + TimeZone.getTimeZone("UTC").getID());
+    assertThat(ret, is(1000L));
+
+    // exceptional scenarios
+    try {
+      ret = toTimestampWithLocalTimeZone("malformed", TimeZone.getDefault());
+      fail("expected error, got " + ret);
+    } catch (CalciteException e) {
+      assertThat(e.getMessage(), containsString("Illegal TIMESTAMP WITH LOCAL TIME ZONE literal"));
+    }
+
+    try {
+      ret = toTimestampWithLocalTimeZone("0000-01-01 00:00:01", TimeZone.getDefault());
+      fail("expected error, got " + ret);
+    } catch (CalciteException e) {
+      assertThat(e.getMessage(), containsString("Illegal TIMESTAMP WITH LOCAL TIME ZONE literal"));
+    }
+
+    try {
+      ret = toTimestampWithLocalTimeZone("malformed " + TimeZone.getDefault().getID());
+      fail("expected error, got " + ret);
+    } catch (CalciteException e) {
+      assertThat(e.getMessage(), containsString("Illegal TIMESTAMP WITH LOCAL TIME ZONE literal"));
+    }
+  }
+
   /**
    * Tests that a nullable timestamp in the given time zone converts to a Unix
    * timestamp in UTC.
diff --git a/core/src/test/resources/sql/misc.iq b/core/src/test/resources/sql/misc.iq
index 74c44a97984f..073d9b480352 100644
--- a/core/src/test/resources/sql/misc.iq
+++ b/core/src/test/resources/sql/misc.iq
@@ -2665,4 +2665,22 @@ group by z;
 
 !ok
 
+# [CALCITE-7001] Cast of malformed literal to TIMESTAMP WITH LOCAL TIME ZONE
+select cast ('malformed' AS TIMESTAMP WITH LOCAL TIME ZONE);
+Illegal TIMESTAMP WITH LOCAL TIME ZONE literal 'malformed'
+!error
+
+# [CALCITE-7001] Cast of malformed literal to TIMESTAMP WITH LOCAL TIME ZONE
+select cast ('0000-01-01 00:00:00' AS TIMESTAMP WITH LOCAL TIME ZONE);
+Illegal TIMESTAMP WITH LOCAL TIME ZONE literal '0000-01-01 00:00:00'
+!error
+
+select TIMESTAMP WITH LOCAL TIME ZONE 'malformed';
+From line 1, column 8 to line 1, column 49: Illegal TIMESTAMP WITH LOCAL TIME ZONE literal 'malformed'
+!error
+
+select TIMESTAMP WITH LOCAL TIME ZONE '0000-01-01 00:00:00';
+From line 1, column 8 to line 1, column 59: Illegal TIMESTAMP WITH LOCAL TIME ZONE literal '0000-01-01 00:00:00'
+!error
+
 # End misc.iq


Reviews:
user: login: mihaibudiu
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-09T01:05:46Z
user: login: mihaibudiu
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-09T01:06:02Z
user: login: xiedeyantu
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-09T01:20:16Z
user: login: xuzifu666
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-09T06:18:06Z
user: login: xuzifu666
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-09T06:18:48Z
user: login: zstan
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-12T10:54:03Z
user: login: zstan
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-12T10:54:39Z
user: login: zstan
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-12T10:58:02Z
user: login: xuzifu666
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-12T11:01:09Z
user: login: xiedeyantu
body: LGTM
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-12T11:10:51Z
user: login: xuzifu666
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-13T02:04:17Z
user: login: mihaibudiu
body: I think you can squash the commits
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T14:37:15Z

Review comments:
diff_hunk: @@ -202,4 +212,17 @@ public TimestampString getLocalTimestampString() {
   public TimeZone getTimeZone() {
     return timeZone;
   }
+
+  private static DateTimeUtils.PrecisionTime parseDateTime(String tsStr, TimeZone timeZone) {
+    DateTimeUtils.PrecisionTime pt =
+        DateTimeUtils.parsePrecisionDateTimeLiteral(tsStr, requireNonNull(TIMESTAMP_FORMAT.get()),
+            timeZone, -1);
+
+    if (pt == null) {
+      throw RESOURCE.illegalLiteral("TIMESTAMP WITH LOCAL TIME ZONE", tsStr,
path: core/src/main/java/org/apache/calcite/util/TimestampWithTimeZoneString.java
user: login: mihaibudiu
body: it is very useful for users to be able to see the illegal timestamp, can it be included in the message?
created_at: 2025-05-09T01:05:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1836,6 +1838,44 @@ private void thereAndBack(byte[] bytes) {
     assertThat(toLongOptional(null), is(nullValue()));
   }
 
+  /**
+   * Test date after 0001-01-01 required by ANSI SQL - is passed.
+   * Test date before 0001-01-01 and malformed date time literal - is failed.
+   */
+  @Test void testToTimestampWithLocalTimeZone() {
+    Long ret = toTimestampWithLocalTimeZone("1970-01-01 00:00:01", TimeZone.getTimeZone("UTC"));
+    assertThat(ret, is(1000L));
+
+    ret = toTimestampWithLocalTimeZone("1970-01-01 00:00:01.010", TimeZone.getTimeZone("UTC"));
+    assertThat(ret, is(1010L));
+
+    ret = toTimestampWithLocalTimeZone("1970-01-01 00:00:01 "
+        + TimeZone.getTimeZone("UTC").getID());
+    assertThat(ret, is(1000L));
+
+    // exceptional scenarios
+    try {
+      ret = toTimestampWithLocalTimeZone("malformed", TimeZone.getDefault());
+      fail("expected error, got " + ret);
+    } catch (RuntimeException e) {
path: core/src/test/java/org/apache/calcite/test/SqlFunctionsTest.java
user: login: xiedeyantu
body: It may be better to change it to `CalciteException` here. 
created_at: 2025-05-09T01:20:16Z
author_association: MEMBER
diff_hunk: @@ -41,21 +46,39 @@ public class TimestampWithTimeZoneString
   final TimestampString localDateTime;
   final TimeZone timeZone;
   final String v;
+  private final DateTimeUtils.PrecisionTime pt;
+
+  private static final ThreadLocal<@Nullable SimpleDateFormat> TIMESTAMP_FORMAT =
path: core/src/main/java/org/apache/calcite/util/TimestampWithTimeZoneString.java
user: login: xuzifu666
body: Can we confirm TimestampWithTimeZoneString##constructor would be call by multiple thread?  If not we may not need to use ThreadLocal.
created_at: 2025-05-09T06:18:03Z
author_association: MEMBER
diff_hunk: @@ -1836,6 +1838,44 @@ private void thereAndBack(byte[] bytes) {
     assertThat(toLongOptional(null), is(nullValue()));
   }
 
+  /**
+   * Test date after 0001-01-01 required by ANSI SQL - is passed.
+   * Test date before 0001-01-01 and malformed date time literal - is failed.
+   */
+  @Test void testToTimestampWithLocalTimeZone() {
+    Long ret = toTimestampWithLocalTimeZone("1970-01-01 00:00:01", TimeZone.getTimeZone("UTC"));
+    assertThat(ret, is(1000L));
+
+    ret = toTimestampWithLocalTimeZone("1970-01-01 00:00:01.010", TimeZone.getTimeZone("UTC"));
+    assertThat(ret, is(1010L));
+
+    ret = toTimestampWithLocalTimeZone("1970-01-01 00:00:01 "
+        + TimeZone.getTimeZone("UTC").getID());
+    assertThat(ret, is(1000L));
+
+    // exceptional scenarios
+    try {
+      ret = toTimestampWithLocalTimeZone("malformed", TimeZone.getDefault());
+      fail("expected error, got " + ret);
+    } catch (RuntimeException e) {
path: core/src/test/java/org/apache/calcite/test/SqlFunctionsTest.java
user: login: xuzifu666
body: +1
created_at: 2025-05-09T06:18:48Z
author_association: MEMBER
diff_hunk: @@ -1836,6 +1838,44 @@ private void thereAndBack(byte[] bytes) {
     assertThat(toLongOptional(null), is(nullValue()));
   }
 
+  /**
+   * Test date after 0001-01-01 required by ANSI SQL - is passed.
+   * Test date before 0001-01-01 and malformed date time literal - is failed.
+   */
+  @Test void testToTimestampWithLocalTimeZone() {
+    Long ret = toTimestampWithLocalTimeZone("1970-01-01 00:00:01", TimeZone.getTimeZone("UTC"));
+    assertThat(ret, is(1000L));
+
+    ret = toTimestampWithLocalTimeZone("1970-01-01 00:00:01.010", TimeZone.getTimeZone("UTC"));
+    assertThat(ret, is(1010L));
+
+    ret = toTimestampWithLocalTimeZone("1970-01-01 00:00:01 "
+        + TimeZone.getTimeZone("UTC").getID());
+    assertThat(ret, is(1000L));
+
+    // exceptional scenarios
+    try {
+      ret = toTimestampWithLocalTimeZone("malformed", TimeZone.getDefault());
+      fail("expected error, got " + ret);
+    } catch (RuntimeException e) {
path: core/src/test/java/org/apache/calcite/test/SqlFunctionsTest.java
user: login: zstan
body: done
created_at: 2025-05-12T10:54:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -202,4 +212,17 @@ public TimestampString getLocalTimestampString() {
   public TimeZone getTimeZone() {
     return timeZone;
   }
+
+  private static DateTimeUtils.PrecisionTime parseDateTime(String tsStr, TimeZone timeZone) {
+    DateTimeUtils.PrecisionTime pt =
+        DateTimeUtils.parsePrecisionDateTimeLiteral(tsStr, requireNonNull(TIMESTAMP_FORMAT.get()),
+            timeZone, -1);
+
+    if (pt == null) {
+      throw RESOURCE.illegalLiteral("TIMESTAMP WITH LOCAL TIME ZONE", tsStr,
path: core/src/main/java/org/apache/calcite/util/TimestampWithTimeZoneString.java
user: login: zstan
body: It already done, i fix a tests
created_at: 2025-05-12T10:54:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -41,21 +46,39 @@ public class TimestampWithTimeZoneString
   final TimestampString localDateTime;
   final TimeZone timeZone;
   final String v;
+  private final DateTimeUtils.PrecisionTime pt;
+
+  private static final ThreadLocal<@Nullable SimpleDateFormat> TIMESTAMP_FORMAT =
path: core/src/main/java/org/apache/calcite/util/TimestampWithTimeZoneString.java
user: login: zstan
body: It not easy, also if it thread safety right now we have no guarantee that it remain same in future.
DateFormat is not thread safe (check java doc) and checker framework reports about erroneous usage without thread local mechanics. Thus i forced to use such a code, if you have different approach here - plz let me know. Thanks !
created_at: 2025-05-12T10:58:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -41,21 +46,39 @@ public class TimestampWithTimeZoneString
   final TimestampString localDateTime;
   final TimeZone timeZone;
   final String v;
+  private final DateTimeUtils.PrecisionTime pt;
+
+  private static final ThreadLocal<@Nullable SimpleDateFormat> TIMESTAMP_FORMAT =
path: core/src/main/java/org/apache/calcite/util/TimestampWithTimeZoneString.java
user: login: xuzifu666
body: OK. if we cannot confirm this maybe a best way to confirm dateformat work well. I agree with it.
created_at: 2025-05-12T11:01:08Z
author_association: MEMBER

PR comments:
user: login: sonarqubecloud[bot]
created_at: 2025-05-14T06:00:00Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=apache_calcite&pullRequest=4363) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [4 New issues](https://sonarcloud.io/project/issues?id=apache_calcite&pullRequest=4363&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=apache_calcite&pullRequest=4363&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=apache_calcite&pullRequest=4363&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [96.4% Coverage on New Code](https://sonarcloud.io/component_measures?id=apache_calcite&pullRequest=4363&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=apache_calcite&pullRequest=4363&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=apache_calcite&pullRequest=4363)


author_association: NONE
user: login: zstan
created_at: 2025-05-14T06:08:25Z
body: @mihaibudiu squashed
author_association: CONTRIBUTOR