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

github-merge-queue[bot] pushed a commit to branch 
gh-readonly-queue/main/pr-5858-3810936b40af3562003120742f7d258b234e4dc5
in repository https://gitbox.apache.org/repos/asf/datafusion-comet.git

commit 334617640e974253843ec5d3bac9ed5db7a7afc8
Author: Peter Lee <[email protected]>
AuthorDate: Sat Sep 12 15:55:01 2026 +0000

    fix: Accept explicit positive years in timestamp casts (#5858)
    
    Co-authored-by: Andy Grove <[email protected]>
---
 native/spark-expr/src/conversion_funcs/string.rs   | 133 +++++++++++++--------
 .../cast/cast_timestamp_signed_year.sql            |  80 +++++++++++++
 .../org/apache/comet/CometNativeCastSuite.scala    |   6 +-
 3 files changed, 167 insertions(+), 52 deletions(-)

diff --git a/native/spark-expr/src/conversion_funcs/string.rs 
b/native/spark-expr/src/conversion_funcs/string.rs
index e56816aed3..b466a5f4a0 100644
--- a/native/spark-expr/src/conversion_funcs/string.rs
+++ b/native/spark-expr/src/conversion_funcs/string.rs
@@ -1133,6 +1133,7 @@ fn parse_to_timestamp_info(
         (1i32, value)
     };
     let mut parts = date_part.split(['T', ' ', '-', ':', '.']);
+    // The integer parser accepts a leading '+', which is not a segment 
separator.
     let year = sign
         * parts
             .next()
@@ -1434,19 +1435,6 @@ fn timestamp_parser<T: TimeZone>(
         };
     }
     let value = trimmed;
-    // Spark accepts a leading '+' year sign on full date-time strings (e.g. 
"+2020-01-01T12:34:56")
-    // but rejects it on time-only strings (e.g. "+12:12:12" -> null).
-    // Detect: '+' followed by at least one digit and then a '-' separator -> 
year prefix -> strip '+'.
-    // Anything else starting with '+' (time-only, bare number, etc.) -> null.
-    let value = if let Some(rest) = value.strip_prefix('+') {
-        let first_non_digit = rest.find(|c: char| !c.is_ascii_digit());
-        match first_non_digit {
-            Some(i) if i >= 1 && rest.as_bytes()[i] == b'-' => rest,
-            _ => return Ok(None),
-        }
-    } else {
-        value
-    };
 
     // Only attempt offset-suffix extraction when the value does not already 
match a
     // base pattern.  This prevents the '-' in plain date strings like 
"2015-03-18"
@@ -1645,29 +1633,33 @@ fn extract_offset_suffix(value: &str) -> Option<(&str, 
Tz)> {
 type TimestampParsePattern<T> = (&'static Regex, fn(&str, &T) -> 
SparkResult<Option<i64>>);
 
 // These shapes transcribe the per-segment digit rules of Spark's
-// `SparkDateTimeUtils.parseTimestampString` (`isValidDigits`): the year takes 
4-6 digits
+// `SparkDateTimeUtils.parseTimestampString` (`isValidDigits`): the year takes 
an optional
+// '+' or '-' sign followed by 4-6 digits
 // (`maxDigitsYear = 6`, so "0002020-01-01" is malformed for a timestamp even 
though
 // `stringToDate`, ported by `date_parser`, allows 7), 
month/day/hour/minute/second take 1-2
 // digits each, and the fraction takes any number of digits including none 
("12:34:56." is
 // valid), of which only the first six are kept. All digits must be ASCII, 
matching Spark's
 // byte scanner and the numeric parsers used after shape recognition.
 // Keep the ASCII ranges: Unicode `\d` also costs substantially more to match 
on valid input.
-static RE_YEAR: LazyLock<Regex> = LazyLock::new(|| 
Regex::new(r"^-?[0-9]{4,6}$").unwrap());
+static RE_YEAR: LazyLock<Regex> = LazyLock::new(|| 
Regex::new(r"^[+-]?[0-9]{4,6}$").unwrap());
 static RE_MONTH: LazyLock<Regex> =
-    LazyLock::new(|| Regex::new(r"^-?[0-9]{4,6}-[0-9]{1,2}$").unwrap());
+    LazyLock::new(|| Regex::new(r"^[+-]?[0-9]{4,6}-[0-9]{1,2}$").unwrap());
 static RE_DAY: LazyLock<Regex> =
-    LazyLock::new(|| 
Regex::new(r"^-?[0-9]{4,6}-[0-9]{1,2}-[0-9]{1,2}$").unwrap());
+    LazyLock::new(|| 
Regex::new(r"^[+-]?[0-9]{4,6}-[0-9]{1,2}-[0-9]{1,2}$").unwrap());
 static RE_HOUR: LazyLock<Regex> =
-    LazyLock::new(|| Regex::new(r"^-?[0-9]{4,6}-[0-9]{1,2}-[0-9]{1,2}[T 
][0-9]{1,2}$").unwrap());
+    LazyLock::new(|| Regex::new(r"^[+-]?[0-9]{4,6}-[0-9]{1,2}-[0-9]{1,2}[T 
][0-9]{1,2}$").unwrap());
 static RE_MINUTE: LazyLock<Regex> = LazyLock::new(|| {
-    Regex::new(r"^-?[0-9]{4,6}-[0-9]{1,2}-[0-9]{1,2}[T 
][0-9]{1,2}:[0-9]{1,2}$").unwrap()
+    Regex::new(r"^[+-]?[0-9]{4,6}-[0-9]{1,2}-[0-9]{1,2}[T 
][0-9]{1,2}:[0-9]{1,2}$").unwrap()
 });
 static RE_SECOND: LazyLock<Regex> = LazyLock::new(|| {
-    Regex::new(r"^-?[0-9]{4,6}-[0-9]{1,2}-[0-9]{1,2}[T 
][0-9]{1,2}:[0-9]{1,2}:[0-9]{1,2}$").unwrap()
+    Regex::new(r"^[+-]?[0-9]{4,6}-[0-9]{1,2}-[0-9]{1,2}[T 
][0-9]{1,2}:[0-9]{1,2}:[0-9]{1,2}$")
+        .unwrap()
 });
 static RE_MICROSECOND: LazyLock<Regex> = LazyLock::new(|| {
-    Regex::new(r"^-?[0-9]{4,6}-[0-9]{1,2}-[0-9]{1,2}[T 
][0-9]{1,2}:[0-9]{1,2}:[0-9]{1,2}\.[0-9]*$")
-        .unwrap()
+    Regex::new(
+        r"^[+-]?[0-9]{4,6}-[0-9]{1,2}-[0-9]{1,2}[T 
][0-9]{1,2}:[0-9]{1,2}:[0-9]{1,2}\.[0-9]*$",
+    )
+    .unwrap()
 });
 static RE_TIME_ONLY_H: LazyLock<Regex> = LazyLock::new(|| 
Regex::new(r"^T[0-9]{1,2}$").unwrap());
 static RE_TIME_ONLY_HM: LazyLock<Regex> =
@@ -1705,7 +1697,7 @@ fn timestamp_parser_with_tz<T: TimeZone>(
     // Both T-separator and space-separator date-time forms are supported.
     // Negative years are handled by get_timestamp_values detecting a leading 
'-'.
     let patterns: &[TimestampParsePattern<T>] = &[
-        // Year only: 4-6 digits, optionally negative
+        // Year only: 4-6 digits, optionally signed
         (
             &RE_YEAR,
             parse_str_to_year_timestamp as fn(&str, &T) -> 
SparkResult<Option<i64>>,
@@ -1779,17 +1771,6 @@ fn timestamp_ntz_parser(
 
     let value = trimmed;
 
-    // Handle leading '+' the same way as timestamp_parser
-    let value = if let Some(rest) = value.strip_prefix('+') {
-        let first_non_digit = rest.find(|c: char| !c.is_ascii_digit());
-        match first_non_digit {
-            Some(i) if i >= 1 && rest.as_bytes()[i] == b'-' => rest,
-            _ => return Ok(None),
-        }
-    } else {
-        value
-    };
-
     // Reject time-only patterns: NTZ requires a date component
     if RE_TIME_ONLY_H.is_match(value)
         || RE_TIME_ONLY_HM.is_match(value)
@@ -2635,19 +2616,77 @@ mod tests {
     #[test]
     fn plus_sign_year_test() {
         let tz = &Tz::from_str("UTC").unwrap();
-        // Spark accepts '+year' prefix on full date-time strings for 
TIMESTAMP casts.
-        // "+2020-01-01T12:34:56" -> 2020-01-01T12:34:56 UTC = 1577882096 
seconds.
-        assert_eq!(
-            timestamp_parser("+2020-01-01T12:34:56", EvalMode::Legacy, tz, 
true).unwrap(),
-            Some(1577882096000000),
-            "+year on full datetime should parse the same as without the + 
prefix"
-        );
-        // But '+' on a time-only string is rejected (Spark returns null).
-        assert_eq!(
-            timestamp_parser("+12:12:12", EvalMode::Legacy, tz, true).unwrap(),
-            None,
-            "+hour:min:sec must return null"
-        );
+        for input in [
+            "7528",
+            "00463",
+            "79821",
+            "2976",
+            "0000",
+            "002020",
+            "2020-1",
+            "2020-1-2",
+            "2020-1-2T3",
+            "2020-1-2 3:4",
+            "2020-1-2T3:4:5",
+            "2020-1-2 3:4:5.",
+            "2020-1-2T3:4:5.123456789",
+            "2020-1-2T3:4:5Z",
+            "2020-1-2T3:4:5+05:30",
+            "2020-1-2T3:4:5-08:00",
+            "2020-1-2T3:4:5 UTC",
+            "294247-01-10T04:00:54.775807",
+        ] {
+            let signed = format!(" +{input} ");
+            for mode in [EvalMode::Legacy, EvalMode::Try, EvalMode::Ansi] {
+                for (actual, expected) in [
+                    (
+                        timestamp_parser(&signed, mode, tz, true),
+                        timestamp_parser(input, mode, tz, true),
+                    ),
+                    (
+                        timestamp_ntz_parser(&signed, mode, true, true),
+                        timestamp_ntz_parser(input, mode, true, true),
+                    ),
+                ] {
+                    let expected = expected.unwrap();
+                    assert!(expected.is_some(), "{input:?}");
+                    assert_eq!(actual.unwrap(), expected, "{signed:?} in 
{mode:?}");
+                }
+            }
+        }
+        for input in [
+            "+",
+            "++2020",
+            "+-2020",
+            "-+2020",
+            "--2020",
+            "+020",
+            "+0002020",
+            "+ 2020",
+            "+2020",
+            "+2020-+1",
+            "+2020--1",
+            "+2020-1-+2",
+            "+12:12:12",
+            "+T12:12:12",
+            "+2020Z",
+            "+2020-1-2Z",
+            "+2020-1-2T3:4Z",
+            "+294247-01-10T04:00:54.775808",
+        ] {
+            for mode in [EvalMode::Legacy, EvalMode::Try, EvalMode::Ansi] {
+                for result in [
+                    timestamp_parser(input, mode, tz, true),
+                    timestamp_ntz_parser(input, mode, true, true),
+                ] {
+                    if mode == EvalMode::Ansi {
+                        assert!(result.is_err(), "{input:?} in {mode:?}");
+                    } else {
+                        assert_eq!(result.unwrap(), None, "{input:?} in 
{mode:?}");
+                    }
+                }
+            }
+        }
     }
 
     #[test]
diff --git 
a/spark/src/test/resources/sql-tests/expressions/cast/cast_timestamp_signed_year.sql
 
b/spark/src/test/resources/sql-tests/expressions/cast/cast_timestamp_signed_year.sql
new file mode 100644
index 0000000000..4d142ab37b
--- /dev/null
+++ 
b/spark/src/test/resources/sql-tests/expressions/cast/cast_timestamp_signed_year.sql
@@ -0,0 +1,80 @@
+-- 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.
+
+-- Config: spark.sql.ansi.enabled=false
+-- ConfigMatrix: spark.sql.session.timeZone=UTC,+05:30
+
+-- Parquet columns exercise the native parser, including every date/time 
segment shape.
+statement
+CREATE TABLE signed_year_valid(s string) USING parquet
+
+statement
+INSERT INTO signed_year_valid VALUES
+  ('+7528'), ('+00463'), ('+79821'), ('+2976'), ('+0000'), ('+002020'),
+  ('+2020-1'), ('+2020-1-2'), ('+2020-1-2T3'), ('+2020-1-2 3:4'),
+  ('+2020-1-2T3:4:5'), ('+2020-1-2 3:4:5.'), ('+2020-1-2T3:4:5.123456789'),
+  ('+2020-1-2T3:4:5Z'), ('+2020-1-2T3:4:5+05:30'), ('+2020-1-2T3:4:5-08:00'),
+  ('+2020-1-2T3:4:5 UTC'), (' +7528 '), ('-0001'), ('-0001-1-2T3:4:5'), (NULL)
+
+statement
+CREATE TABLE signed_year_invalid(s string) USING parquet
+
+statement
+INSERT INTO signed_year_invalid VALUES
+  ('+'), ('++2020'), ('+-2020'), ('-+2020'), ('--2020'), ('+020'), 
('+0002020'),
+  ('+ 2020'), ('+2020'), ('+2020-+1'), ('+2020--1'), ('+2020-1-+2'),
+  ('+12:12:12'), ('+T12:12:12'), ('+2020Z'), ('+2020-1-2Z'), 
('+2020-1-2T3:4Z'),
+  ('++2020-1-2'), ('+-2020-1-2')
+
+query expect_native(cast)
+SELECT s, cast(s AS timestamp), cast(s AS timestamp_ntz) FROM signed_year_valid
+
+query expect_native(cast)
+SELECT s, cast(s AS timestamp), cast(s AS timestamp_ntz) FROM 
signed_year_invalid
+
+query expect_native(try_cast)
+SELECT s, try_cast(s AS timestamp), try_cast(s AS timestamp_ntz)
+FROM (SELECT s FROM signed_year_valid UNION ALL SELECT s FROM 
signed_year_invalid)
+
+statement
+SET spark.sql.ansi.enabled=true
+
+query expect_native(cast)
+SELECT s, cast(s AS timestamp), cast(s AS timestamp_ntz) FROM signed_year_valid
+
+query expect_native(try_cast)
+SELECT s, try_cast(s AS timestamp), try_cast(s AS timestamp_ntz)
+FROM (SELECT s FROM signed_year_valid UNION ALL SELECT s FROM 
signed_year_invalid)
+
+-- Each malformed sign must throw independently in ANSI mode.
+query expect_error(CAST_INVALID_INPUT)
+SELECT cast(s AS timestamp) FROM signed_year_invalid WHERE s = '++2020'
+
+query expect_error(CAST_INVALID_INPUT)
+SELECT cast(s AS timestamp_ntz) FROM signed_year_invalid WHERE s = '++2020'
+
+query expect_error(CAST_INVALID_INPUT)
+SELECT cast(s AS timestamp) FROM signed_year_invalid WHERE s = '+-2020-1-2'
+
+query expect_error(CAST_INVALID_INPUT)
+SELECT cast(s AS timestamp_ntz) FROM signed_year_invalid WHERE s = '+-2020-1-2'
+
+query expect_error(CAST_INVALID_INPUT)
+SELECT cast(s AS timestamp) FROM signed_year_invalid WHERE s = '+12:12:12'
+
+query expect_error(CAST_INVALID_INPUT)
+SELECT cast(s AS timestamp_ntz) FROM signed_year_invalid WHERE s = '+12:12:12'
diff --git a/spark/src/test/scala/org/apache/comet/CometNativeCastSuite.scala 
b/spark/src/test/scala/org/apache/comet/CometNativeCastSuite.scala
index e96367bfe5..3c9dbacd15 100644
--- a/spark/src/test/scala/org/apache/comet/CometNativeCastSuite.scala
+++ b/spark/src/test/scala/org/apache/comet/CometNativeCastSuite.scala
@@ -1432,11 +1432,7 @@ class CometNativeCastSuite extends CometTestBase with 
AdaptiveSparkPlanHelper {
 
   test("cast StringType to TimestampType") {
     withSQLConf((SQLConf.SESSION_LOCAL_TIMEZONE.key, "UTC")) {
-      // Spark accepts explicit positive years; Comet does not yet (#5716).
-      // Keep the wider alphabet, excluding only the known bare-year mismatch.
-      val fuzzValues = gen
-        .generateStrings(dataSize, timestampPattern, 8)
-        .filterNot(_.trim.matches("\\+[0-9]{4,6}"))
+      val fuzzValues = gen.generateStrings(dataSize, timestampPattern, 8)
       val values = Seq("2020-01-01T12:34:56.123456", "T2") ++ fuzzValues
       castTest(values.toDF("a"), DataTypes.TimestampType)
     }


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to