This is an automated email from the ASF dual-hosted git repository.
sunchao pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/datafusion-comet.git
The following commit(s) were added to refs/heads/main by this push:
new c152051895 fix: align string to timestamp parsing with Spark's segment
rules (#5682)
c152051895 is described below
commit c1520518953473b4430941ef9dbf86ebd6e400e2
Author: Peter Lee <[email protected]>
AuthorDate: Thu Sep 10 08:14:42 2026 +0800
fix: align string to timestamp parsing with Spark's segment rules (#5682)
* fix: align string to timestamp parsing with Spark's segment rules
Spark's SparkDateTimeUtils.parseTimestampString validates each segment
with isValidDigits: month, day, hour, minute and second take 1-2 digits,
the fraction after '.' may be empty, a timestamp year takes at most 6
digits (only stringToDate allows 7), and a zone id is only captured when
the scanner is inside the seconds or fraction segment.
Comet's regex table required exactly 2 digits and a non-empty fraction,
allowed 7-digit years, and stripped a zone suffix from any shape, so
'2020-1-1', '2020-01-01 12:34:5' and '2020-01-01 12:34:56.' returned
NULL (or raised under ANSI) while '2020-10-01Z' and
'0002020-01-01 00:00:00' were accepted. Relax the segment quantifiers,
cap the year at 6 digits for timestamp shapes, allow an empty fraction,
and only honour a stripped suffix when the remainder ends in a seconds
or fraction segment, for both TIMESTAMP and TIMESTAMP_NTZ. Previously
accepted inputs keep their exact values; CAST(... AS DATE) keeps its
7-digit years because date_parser is a separate port of stringToDate.
Closes #5674
Co-Authored-By: Claude Fable 5.1 <[email protected]>
* fix: restrict timestamp segments to ASCII digits
* fix: use ASCII digits throughout timestamp patterns
* fix: address timestamp parser review regressions and fuzz coverage
* Preserve Spark 4 leading-whitespace rejection with timestamp offsets
* fix: match Java trimming for timestamp zone separators
* fix: validate numeric timestamp offsets before normalization
---------
Co-authored-by: Claude Fable 5.1 <[email protected]>
---
.../user-guide/latest/compatibility/index.md | 3 +
.../spark-expr/benches/cast_string_to_timestamp.rs | 25 +-
native/spark-expr/src/conversion_funcs/string.rs | 555 ++++++++++++++++++---
.../org/apache/comet/CometNativeCastSuite.scala | 162 +++++-
4 files changed, 668 insertions(+), 77 deletions(-)
diff --git a/docs/source/user-guide/latest/compatibility/index.md
b/docs/source/user-guide/latest/compatibility/index.md
index 001ec87606..fd7c15ff3d 100644
--- a/docs/source/user-guide/latest/compatibility/index.md
+++ b/docs/source/user-guide/latest/compatibility/index.md
@@ -139,6 +139,9 @@ so users hunting an unexpected value have a single place to
check:
parses in Spark and returns `NULL` in Comet, while a value padded with
non-ASCII whitespace such
as `U+3000` returns `NULL` in Spark and parses in Comet
([#5149](https://github.com/apache/datafusion-comet/issues/5149)).
+- **Explicit positive timestamp years:** Spark accepts strings such as `+7528`
as the start
+ of that year, while Comet's native string-to-timestamp cast returns NULL in
non-ANSI mode
+ ([#5716](https://github.com/apache/datafusion-comet/issues/5716)).
- Native `RANGE` window frames with an explicit `PRECEDING` / `FOLLOWING`
offset diverge from
Spark when the boundary arithmetic overflows for `DATE` or `DECIMAL` `ORDER
BY` columns
([#5022](https://github.com/apache/datafusion-comet/issues/5022)).
diff --git a/native/spark-expr/benches/cast_string_to_timestamp.rs
b/native/spark-expr/benches/cast_string_to_timestamp.rs
index 3e83d78756..e28afb8272 100644
--- a/native/spark-expr/benches/cast_string_to_timestamp.rs
+++ b/native/spark-expr/benches/cast_string_to_timestamp.rs
@@ -30,11 +30,22 @@ const BATCH_SIZE: usize = 8192;
fn criterion_benchmark(c: &mut Criterion) {
let expr = Arc::new(Column::new("a", 0)) as Arc<dyn PhysicalExpr>;
- // Input shapes, chosen to cover each branch `timestamp_parser` can take:
the canonical
- // form, the fractional-second form, an offset suffix (which takes the
extract-offset
- // path), a date-only string, whitespace padding (the trim), and a mix
that includes
- // invalid values so the null path is measured too.
+ // Cover single-digit segments, empty fractions, rejected date-only zones,
canonical
+ // timestamps, microseconds, offset extraction, date-only input,
whitespace padding,
+ // and a mix containing invalid values so the null path is measured too.
let batches = [
+ (
+ "single_digit_segments",
+ create_batch(|i| format!("2020-{:02}-{}T1:2:3", i % 12 + 1, i % 9
+ 1)),
+ ),
+ (
+ "empty_fraction",
+ create_batch(|i| format!("2020-01-{:02}T12:34:56.", i % 28 + 1)),
+ ),
+ (
+ "date_only_zone",
+ create_batch(|i| format!("2020-01-{:02}Z", i % 28 + 1)),
+ ),
(
"canonical",
create_batch(|i| {
@@ -151,7 +162,7 @@ fn criterion_benchmark(c: &mut Criterion) {
for (name, batch) in &batches {
// ANSI raises on the first invalid value, so timing it
against a batch that is
// mostly invalid would measure the error path rather than the
parser.
- if mode == EvalMode::Ansi && *name == "mixed" {
+ if mode == EvalMode::Ansi && matches!(*name, "mixed" |
"date_only_zone") {
continue;
}
let cast = Cast::new(
@@ -169,8 +180,8 @@ fn criterion_benchmark(c: &mut Criterion) {
}
}
- // The Spark 4 path adds a leading-whitespace check for T-prefixed
time-only strings, so it
- // is measured separately on the inputs where that check can fire.
+ // Measure the cost of Spark 4's leading-whitespace/T-prefix check.
Neither padded nor
+ // mixed combines both conditions, so these inputs never take its
rejection branch.
let mut group =
c.benchmark_group("cast_string_to_timestamp/spark4_legacy");
for name in ["padded", "mixed"] {
let batch = &batches.iter().find(|(n, _)| *n == name).unwrap().1;
diff --git a/native/spark-expr/src/conversion_funcs/string.rs
b/native/spark-expr/src/conversion_funcs/string.rs
index e460d2dfc6..e56816aed3 100644
--- a/native/spark-expr/src/conversion_funcs/string.rs
+++ b/native/spark-expr/src/conversion_funcs/string.rs
@@ -1153,11 +1153,15 @@ fn parse_to_timestamp_info(
let hour = parts.next().map_or(0, |h| h.parse::<u32>().unwrap_or(0));
let minute = parts.next().map_or(0, |m| m.parse::<u32>().unwrap_or(0));
let second = parts.next().map_or(0, |s| s.parse::<u32>().unwrap_or(0));
- let microsecond = parts.next().map_or(0, |ms| {
- let ms = &ms[..ms.len().min(6)];
+ let microsecond = if let Some(ms) = parts.next() {
+ let Some(ms) = ms.get(..ms.len().min(6)) else {
+ return Ok(None);
+ };
let n = ms.len();
ms.parse::<u32>().unwrap_or(0) * 10u32.pow((6 - n) as u32)
- });
+ } else {
+ 0
+ };
let mut timestamp_info = TimeStampInfo::default();
@@ -1417,14 +1421,8 @@ fn timestamp_parser<T: TimeZone>(
// Spark 4.0+ rejects leading whitespace for ALL T-prefixed time-only
strings
// (T<h>, T<h>:<m>, T<h>:<m>:<s>, T<h>:<m>:<s>.<f>), but accepts trailing
whitespace.
// Spark 3.x trims all whitespace first, so leading whitespace is accepted
there.
- // Check the raw (pre-trim) value for leading whitespace before any
T-time-only match.
- if is_spark4_plus
- && value.len() > value.trim_start().len()
- && (RE_TIME_ONLY_H.is_match(trimmed)
- || RE_TIME_ONLY_HM.is_match(trimmed)
- || RE_TIME_ONLY_HMS.is_match(trimmed)
- || RE_TIME_ONLY_HMSU.is_match(trimmed))
- {
+ // Check the prefix, not the base patterns: a zone suffix can hide a
time-only match.
+ if is_spark4_plus && value.len() > value.trim_start().len() &&
trimmed.starts_with('T') {
return if eval_mode == EvalMode::Ansi {
Err(SparkError::InvalidInputInCastToDatetime {
value: value.to_string(),
@@ -1470,7 +1468,14 @@ fn timestamp_parser<T: TimeZone>(
if !has_direct_match {
if let Some((stripped, suffix_tz)) = extract_offset_suffix(value) {
- return timestamp_parser_with_tz(stripped, eval_mode, &suffix_tz);
+ // Spark applies Java String.trim to the zone, not Unicode
whitespace trimming.
+ let stripped = stripped.trim_end_matches(|c: char| c <= '\u{20}');
+ // A zone suffix is only meaningful after the seconds segment.
Otherwise fall
+ // through with the unstripped value, which no base pattern
matches, so it is
+ // reported as malformed (null, or CAST_INVALID_INPUT under ANSI)
like Spark does.
+ if ends_with_seconds_segment(stripped) {
+ return timestamp_parser_with_tz(stripped, eval_mode,
&suffix_tz);
+ }
}
}
@@ -1490,7 +1495,8 @@ fn timestamp_parser<T: TimeZone>(
/// "+HH:MM" -> same
/// (negative with '-' analogously)
///
-/// Hours must be 0–18 and minutes 0–59. A trailing colon ("+8:") is rejected.
+/// Hours must be 0–18 and minutes 0–59, with a maximum absolute offset of
18:00.
+/// A trailing colon ("+8:") is rejected.
fn parse_sign_offset(s: &str) -> Option<i32> {
if s.is_empty() {
return Some(0);
@@ -1500,14 +1506,16 @@ fn parse_sign_offset(s: &str) -> Option<i32> {
Some(&b'-') => (-1i32, &s[1..]),
_ => return None,
};
- if rest.is_empty() {
- return None; // lone '+' or '-'
+ // Validate before slicing: malformed date segments can reach this helper,
and a
+ // byte range such as rest[..2] must not split a non-ASCII digit's UTF-8
encoding.
+ if rest.is_empty() || !rest.bytes().all(|b| b.is_ascii_digit() || b ==
b':') {
+ return None;
}
let (h, m) = if let Some(colon_pos) = rest.find(':') {
let h_str = &rest[..colon_pos];
let m_str = &rest[colon_pos + 1..];
- if m_str.is_empty() {
- return None; // trailing colon: "+8:"
+ if !(1..=2).contains(&h_str.len()) || !(1..=2).contains(&m_str.len()) {
+ return None;
}
let h: i32 = h_str.parse().ok()?;
// Note: "+HH:MM:SS" (with seconds) is not handled; Spark accepts it
but it is rare.
@@ -1523,7 +1531,7 @@ fn parse_sign_offset(s: &str) -> Option<i32> {
_ => return None,
}
};
- if !(0..=18).contains(&h) || !(0..=59).contains(&m) {
+ if !(0..=18).contains(&h) || !(0..=59).contains(&m) || (h == 18 && m != 0)
{
return None;
}
Some(sign * (h * 3600 + m * 60))
@@ -1636,34 +1644,58 @@ fn extract_offset_suffix(value: &str) -> Option<(&str,
Tz)> {
type TimestampParsePattern<T> = (&'static Regex, fn(&str, &T) ->
SparkResult<Option<i64>>);
-// RE_YEAR allows only 4-6 digits (not 7) because a bare 7-digit string like
"0119704"
-// is ambiguous and Spark rejects it. The other patterns (RE_MONTH, RE_DAY,
etc.) keep
-// \d{4,7} because the `-` separator disambiguates the year portion, so
"0002020-01-01"
-// is validly year 2020 with leading zeros. date_parser's is_valid_digits also
allows up
-// to 7 year digits for the same reason.
-static RE_YEAR: LazyLock<Regex> = LazyLock::new(||
Regex::new(r"^-?\d{4,6}$").unwrap());
-static RE_MONTH: LazyLock<Regex> = LazyLock::new(||
Regex::new(r"^-?\d{4,7}-\d{2}$").unwrap());
-static RE_DAY: LazyLock<Regex> = LazyLock::new(||
Regex::new(r"^-?\d{4,7}-\d{2}-\d{2}$").unwrap());
+// These shapes transcribe the per-segment digit rules of Spark's
+// `SparkDateTimeUtils.parseTimestampString` (`isValidDigits`): the year takes
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_MONTH: LazyLock<Regex> =
+ 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());
static RE_HOUR: LazyLock<Regex> =
- LazyLock::new(|| Regex::new(r"^-?\d{4,7}-\d{2}-\d{2}[T
]\d{1,2}$").unwrap());
-static RE_MINUTE: LazyLock<Regex> =
- LazyLock::new(|| Regex::new(r"^-?\d{4,7}-\d{2}-\d{2}[T
]\d{2}:\d{2}$").unwrap());
-static RE_SECOND: LazyLock<Regex> =
- LazyLock::new(|| Regex::new(r"^-?\d{4,7}-\d{2}-\d{2}[T
]\d{2}:\d{2}:\d{2}$").unwrap());
-static RE_MICROSECOND: LazyLock<Regex> =
- LazyLock::new(|| Regex::new(r"^-?\d{4,7}-\d{2}-\d{2}[T
]\d{2}:\d{2}:\d{2}\.\d+$").unwrap());
-static RE_TIME_ONLY_H: LazyLock<Regex> = LazyLock::new(||
Regex::new(r"^T\d{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()
+});
+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()
+});
+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()
+});
+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> =
- LazyLock::new(|| Regex::new(r"^T\d{1,2}:\d{1,2}$").unwrap());
+ LazyLock::new(|| Regex::new(r"^T[0-9]{1,2}:[0-9]{1,2}$").unwrap());
static RE_TIME_ONLY_HMS: LazyLock<Regex> =
- LazyLock::new(|| Regex::new(r"^T\d{1,2}:\d{1,2}:\d{1,2}$").unwrap());
+ LazyLock::new(||
Regex::new(r"^T[0-9]{1,2}:[0-9]{1,2}:[0-9]{1,2}$").unwrap());
static RE_TIME_ONLY_HMSU: LazyLock<Regex> =
- LazyLock::new(|| Regex::new(r"^T\d{1,2}:\d{1,2}:\d{1,2}\.\d+$").unwrap());
-static RE_BARE_HM: LazyLock<Regex> = LazyLock::new(||
Regex::new(r"^\d{1,2}:\d{1,2}$").unwrap());
+ LazyLock::new(||
Regex::new(r"^T[0-9]{1,2}:[0-9]{1,2}:[0-9]{1,2}\.[0-9]*$").unwrap());
+static RE_BARE_HM: LazyLock<Regex> =
+ LazyLock::new(|| Regex::new(r"^[0-9]{1,2}:[0-9]{1,2}$").unwrap());
static RE_BARE_HMS: LazyLock<Regex> =
- LazyLock::new(|| Regex::new(r"^\d{1,2}:\d{1,2}:\d{1,2}$").unwrap());
+ LazyLock::new(||
Regex::new(r"^[0-9]{1,2}:[0-9]{1,2}:[0-9]{1,2}$").unwrap());
static RE_BARE_HMSU: LazyLock<Regex> =
- LazyLock::new(|| Regex::new(r"^\d{1,2}:\d{1,2}:\d{1,2}\.\d+$").unwrap());
+ LazyLock::new(||
Regex::new(r"^[0-9]{1,2}:[0-9]{1,2}:[0-9]{1,2}\.[0-9]*$").unwrap());
+
+/// Whether `value` (a datetime with any zone suffix already stripped) ends in
a seconds or
+/// fraction segment. Spark's `parseTimestampString` only captures a zone id
when its byte
+/// scanner hits a non-digit while inside those two segments, so a suffix such
as `Z`, `+05:30`
+/// or ` UTC` is legal after `hh:mm:ss` or `hh:mm:ss.f*` but makes a
date-only, hour-only or
+/// hour:minute value malformed ("2020-10-01Z" and "2020-01-01T12:34Z" are
both null).
+fn ends_with_seconds_segment(value: &str) -> bool {
+ RE_SECOND.is_match(value)
+ || RE_MICROSECOND.is_match(value)
+ || RE_TIME_ONLY_HMS.is_match(value)
+ || RE_TIME_ONLY_HMSU.is_match(value)
+ || RE_BARE_HMS.is_match(value)
+ || RE_BARE_HMSU.is_match(value)
+}
fn timestamp_parser_with_tz<T: TimeZone>(
value: &str,
@@ -1673,7 +1705,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-7 digits, optionally negative
+ // Year only: 4-6 digits, optionally negative
(
&RE_YEAR,
parse_str_to_year_timestamp as fn(&str, &T) ->
SparkResult<Option<i64>>,
@@ -1787,23 +1819,30 @@ fn timestamp_ntz_parser(
|| RE_SECOND.is_match(value)
|| RE_MICROSECOND.is_match(value);
- // If no direct match, try stripping a timezone suffix
+ // If no direct match, try stripping a timezone suffix. Spark only
recognises a zone after
+ // the seconds segment; a suffix anywhere else leaves the unstripped
value, which no base
+ // pattern matches, so the inner parser reports it as malformed.
let value_to_parse = if !has_direct_match {
- if let Some((stripped, _tz)) = extract_offset_suffix(value) {
- if !allow_time_zone {
- return if eval_mode == EvalMode::Ansi {
- Err(SparkError::InvalidInputInCastToDatetime {
- value: value.to_string(),
- from_type: "STRING".to_string(),
- to_type: "TIMESTAMP_NTZ".to_string(),
- })
- } else {
- Ok(None)
- };
+ match extract_offset_suffix(value) {
+ Some((stripped, _tz))
+ if ends_with_seconds_segment(
+ stripped.trim_end_matches(|c: char| c <= '\u{20}'),
+ ) =>
+ {
+ if !allow_time_zone {
+ return if eval_mode == EvalMode::Ansi {
+ Err(SparkError::InvalidInputInCastToDatetime {
+ value: value.to_string(),
+ from_type: "STRING".to_string(),
+ to_type: "TIMESTAMP_NTZ".to_string(),
+ })
+ } else {
+ Ok(None)
+ };
+ }
+ stripped.trim_end_matches(|c: char| c <= '\u{20}')
}
- stripped.trim_end()
- } else {
- value
+ _ => value,
}
} else {
value
@@ -1862,7 +1901,9 @@ fn parse_str_to_time_only_timestamp<T: TimeZone>(value:
&str, tz: &T) -> SparkRe
let ns: u32 = if let Some(dot) = dot_idx {
let frac = &sec_frac[dot + 1..];
// Interpret up to 6 digits as microseconds, padding with trailing
zeros.
- let trimmed = &frac[..frac.len().min(6)];
+ let Some(trimmed) = frac.get(..frac.len().min(6)) else {
+ return Ok(None);
+ };
let padded = format!("{:0<6}", trimmed);
padded.parse::<u32>().unwrap_or(0) * 1000
} else {
@@ -2549,13 +2590,24 @@ mod tests {
fn test_leading_whitespace_t_hm() {
let tz = &Tz::from_str("UTC").unwrap();
// Spark 4.0+ rejects leading whitespace for ALL T-prefixed time-only
patterns.
- for ws_input in &[" T2:30", "\tT2:30", "\nT2:30", " T2", "\tT2",
"\nT2"] {
- assert!(
- timestamp_parser(ws_input, EvalMode::Legacy, tz, true)
- .unwrap()
- .is_none(),
- "'{ws_input}' should be null in Legacy mode on Spark 4.0+"
- );
+ for ws_input in &[
+ " T2:30",
+ "\tT2:30",
+ "\nT2:30",
+ " T2",
+ "\tT2",
+ "\nT2",
+ "\tT1:2:3 +08:00",
+ " T1:2:3.4 +08:00",
+ ] {
+ for mode in [EvalMode::Legacy, EvalMode::Try] {
+ assert!(
+ timestamp_parser(ws_input, mode, tz, true)
+ .unwrap()
+ .is_none(),
+ "'{ws_input}' should be null in {mode:?} mode on Spark
4.0+"
+ );
+ }
// In ANSI mode the same inputs must raise an error (not silently
return null).
assert!(
timestamp_parser(ws_input, EvalMode::Ansi, tz, true).is_err(),
@@ -2570,7 +2622,7 @@ mod tests {
);
}
// Without leading whitespace, these must be valid on all versions.
- for ok_input in &["T2:30", "T2"] {
+ for ok_input in &["T2:30", "T2", "T1:2:3 +08:00", "T1:2:3.4 +08:00"] {
assert!(
timestamp_parser(ok_input, EvalMode::Legacy, tz, true)
.unwrap()
@@ -2956,6 +3008,379 @@ mod tests {
);
}
+ // 2020-01-01T00:00:00Z, 2020-01-01T12:34:56Z and the same wall clock at
+05:30, in micros.
+ const JAN1_2020: i64 = 1577836800000000;
+ const JAN1_2020_123456: i64 = 1577882096000000;
+ const JAN1_2020_123456_PLUS_0530: i64 = 1577862296000000;
+
+ /// Inputs Spark's `parseTimestampString` accepts that the fixed 2-digit
shapes rejected:
+ /// 1-2 digit month/day/hour/minute/second, an empty fraction (also before
a zone), and
+ /// 6-digit years (issue #5674). Values are UTC micros, identical for
TIMESTAMP_NTZ.
+ const SPARK_SEGMENT_RULE_VALID: &[(&str, i64)] = &[
+ ("2020-10-1", 1_601_510_400_000_000),
+ ("2020-12-1", 1_606_780_800_000_000),
+ ("2020-1", JAN1_2020),
+ ("2020-1-1", JAN1_2020),
+ ("2020-1-1T1", JAN1_2020 + 3600 * 1_000_000),
+ ("2020-1-1 1:2", JAN1_2020 + 3720 * 1_000_000),
+ ("2020-01-01 12:34:5", JAN1_2020 + 45245 * 1_000_000),
+ ("2020-1-1T1:2:3.4", JAN1_2020 + 3723 * 1_000_000 + 400_000),
+ ("2020-01-01 12:34:56.", JAN1_2020_123456),
+ ("002020-01-01 00:00:00", JAN1_2020),
+ ];
+
+ /// Inputs Spark rejects: non-ASCII segment digits, a zone suffix anywhere
but after the
+ /// seconds segment, more than six year digits, and more than two digits
in any other segment.
+ const SPARK_SEGMENT_RULE_INVALID: &[&str] = &[
+ "2020-01-01 12:34:56.1٢٢٢",
+ "T1:2:3.1٢٢٢",
+ "2020-1-1T٢",
+ "2020-1-1T1:2:3.٢",
+ "٢020-1-1",
+ "2020-٢",
+ "2020-01-٢",
+ "2020-1\u{967}",
+ "2020-\u{967}1",
+ "2020-01-1\u{967}",
+ "2020-01-\u{967}1",
+ "2020-01-01 12:34:56 +08:000",
+ "2020-01-01 12:34:56 +008:00",
+ "2020-01-01 12:34:56 +18:01",
+ "2020-01-01 12:34:56 -18:01",
+ "2020-01-01 12:34:56+08:000",
+ "2020-01-01 12:34:56+008:00",
+ "2020-01-01 12:34:56+18:01",
+ "2020-01-01 12:34:56 UTC+08:000",
+ "2020-01-01 12:34:56 GMT+008:00",
+ "2020-01-01 12:34:56 UT+18:01",
+ "2020-01-01T1:٢",
+ "2020-01-01T1:2:٣",
+ "2020-1-1T1:2:3.٢Z",
+ "T٢",
+ "T1:٢",
+ "T1:2:٣",
+ "T1:2:3.٢",
+ "1:٢",
+ "1:2:٣",
+ "1:2:3.٢",
+ "2020Z",
+ "2020-10-01Z",
+ "2020-01-01+05:30",
+ "2020-01-01-08:00",
+ "2020-10-01 UTC",
+ "2020-01-01T12Z",
+ "2020-01-01 12 UTC",
+ "2020-01-01T12:34Z",
+ "2020-01-01 12:34 UTC",
+ "2020-01-01T12:34:Z",
+ "0002020-01-01",
+ "0002020-01-01 00:00:00",
+ "-0002020-01-01",
+ "2020-001-01",
+ "2020-01-001",
+ "2020-01-01T123",
+ "2020-01-01T12:345",
+ "2020-01-01T12:34:567",
+ ];
+
+ #[test]
+ #[cfg_attr(miri, ignore)]
+ fn timestamp_zone_whitespace_matches_java_trim() {
+ let tz = &Tz::from_str("UTC").unwrap();
+ for whitespace in [
+ " ", "\t", "\n", "\u{1}", "\u{b}", "\u{c}", "\u{7f}", "\u{a0}",
"\u{2009}", "\u{3000}",
+ ] {
+ let valid = whitespace.chars().all(|c| c <= '\u{20}');
+ for (suffix, offset) in [("+08:00", 28_800_000_000), ("UTC", 0),
("Z", 0)] {
+ for (fraction, micros) in [("", 0), (".123", 123_000)] {
+ let input = format!("2020-01-01
12:34:56{fraction}{whitespace}{suffix}");
+ for mode in [EvalMode::Legacy, EvalMode::Try,
EvalMode::Ansi] {
+ for spark4 in [false, true] {
+ for (result, expected) in [
+ (
+ timestamp_parser(&input, mode, tz, spark4),
+ JAN1_2020_123456 + micros - offset,
+ ),
+ (
+ timestamp_ntz_parser(&input, mode, true,
spark4),
+ JAN1_2020_123456 + micros,
+ ),
+ ] {
+ if valid {
+ assert_eq!(
+ result.unwrap(),
+ Some(expected),
+ "{input:?}, {mode:?}"
+ );
+ } else if mode == EvalMode::Ansi {
+ assert!(
+ matches!(
+ result,
+
Err(SparkError::InvalidInputInCastToDatetime { .. })
+ ),
+ "{input:?}"
+ );
+ } else {
+ assert_eq!(result.unwrap(), None,
"{input:?}, {mode:?}");
+ }
+ }
+ let no_zone = timestamp_ntz_parser(&input, mode,
false, spark4);
+ if mode == EvalMode::Ansi {
+ assert!(no_zone.is_err(), "{input:?}");
+ } else {
+ assert_eq!(no_zone.unwrap(), None,
"{input:?}");
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ #[test]
+ fn timestamp_numeric_offset_validation() {
+ for (input, seconds) in [
+ ("", 0),
+ ("+0", 0),
+ ("-00", 0),
+ ("+8", 28_800),
+ ("+08", 28_800),
+ ("+0800", 28_800),
+ ("+8:0", 28_800),
+ ("+08:0", 28_800),
+ ("+8:00", 28_800),
+ ("+17:59", 64_740),
+ ("+18:00", 64_800),
+ ("-18:00", -64_800),
+ ("+1800", 64_800),
+ ("-1800", -64_800),
+ ] {
+ assert_eq!(parse_sign_offset(input), Some(seconds), "{input:?}");
+ }
+ for input in [
+ "+08:000",
+ "+008:00",
+ "+18:01",
+ "-18:01",
+ "+18:59",
+ "+19",
+ "-1900",
+ "+8:",
+ "+1:+1",
+ "-1:-1",
+ "+1\u{967}",
+ "+\u{967}1",
+ "+1٢",
+ "++1",
+ ] {
+ assert_eq!(parse_sign_offset(input), None, "{input:?}");
+ }
+ }
+
+ #[test]
+ #[cfg_attr(miri, ignore)]
+ fn timestamp_parser_spark_segment_rules_test() {
+ let tz = &Tz::from_str("UTC").unwrap();
+ // Exercise the decoders without their regex gates: malformed UTF-8
boundaries
+ // must not panic even if the accepted patterns change later.
+ assert!(
+ parse_to_timestamp_info("2020-01-01 12:34:56.1٢٢٢", "microsecond")
+ .unwrap()
+ .is_none()
+ );
+ assert_eq!(
+ parse_str_to_time_only_timestamp("T1:2:3.1٢٢٢", tz).unwrap(),
+ None
+ );
+ for mode in [EvalMode::Legacy, EvalMode::Try, EvalMode::Ansi] {
+ assert_eq!(
+ timestamp_parser("2021-11-22 10:54:27 +08:00", mode, tz,
true).unwrap(),
+ Some(1_637_549_667_000_000)
+ );
+ assert_eq!(
+ timestamp_ntz_parser("2021-11-22 10:54:27 +08:00", mode, true,
true).unwrap(),
+ Some(1_637_578_467_000_000)
+ );
+ }
+ for &(input, expected) in SPARK_SEGMENT_RULE_VALID {
+ for eval_mode in [EvalMode::Legacy, EvalMode::Try, EvalMode::Ansi]
{
+ assert_eq!(
+ timestamp_parser(input, eval_mode, tz, true).unwrap(),
+ Some(expected),
+ "{input:?} in {eval_mode:?}"
+ );
+ }
+ }
+ // An empty fraction may still be followed by a zone.
+ assert_eq!(
+ timestamp_parser("2020-01-01 12:34:56.Z", EvalMode::Legacy, tz,
true).unwrap(),
+ Some(JAN1_2020_123456)
+ );
+ assert_eq!(
+ timestamp_parser("2020-01-01 12:34:56.+05:30", EvalMode::Legacy,
tz, true).unwrap(),
+ Some(JAN1_2020_123456_PLUS_0530)
+ );
+
+ // Time-only shapes may carry a zone after their seconds segment but
not before it.
+ for input in ["T12:34:56Z", "12:34:56+05:30", "T1:2:3.Z"] {
+ assert!(
+ timestamp_parser(input, EvalMode::Ansi, tz, true)
+ .unwrap()
+ .is_some(),
+ "{input:?}"
+ );
+ }
+ for input in
+ SPARK_SEGMENT_RULE_INVALID
+ .iter()
+ .copied()
+ .chain(["T12Z", "12:34Z", "T12:34 UTC"])
+ {
+ for eval_mode in [EvalMode::Legacy, EvalMode::Try] {
+ assert_eq!(
+ timestamp_parser(input, eval_mode, tz, true).unwrap(),
+ None,
+ "{input:?} in {eval_mode:?}"
+ );
+ }
+ assert!(
+ timestamp_parser(input, EvalMode::Ansi, tz, true).is_err(),
+ "{input:?} in Ansi"
+ );
+ }
+
+ // Shapes that were already accepted keep their exact values.
+ let la_offset = 8 * 3600 * 1_000_000; // America/Los_Angeles is UTC-8
in January
+ for (input, expected) in [
+ ("2020-01-01", JAN1_2020),
+ ("2020-01-01 12:34:56", JAN1_2020_123456),
+ ("2020-01-01T12:34:56.123456", JAN1_2020_123456 + 123456),
+ ("2020-01-01T12:34:56Z", JAN1_2020_123456),
+ ("2020-01-01T12:34:56.123Z", JAN1_2020_123456 + 123000),
+ ("2020-01-01T12:34:56+05:30", JAN1_2020_123456_PLUS_0530),
+ ("2020-01-01T12:34:56 UTC", JAN1_2020_123456),
+ ("2020-01-01T12:34:56 UTC+5:30", JAN1_2020_123456_PLUS_0530),
+ (
+ "2020-01-01T12:34:56 America/Los_Angeles",
+ JAN1_2020_123456 + la_offset,
+ ),
+ ("-0001-01-01T12:34:56", -62198709904000000),
+ ] {
+ for eval_mode in [EvalMode::Legacy, EvalMode::Try, EvalMode::Ansi]
{
+ assert_eq!(
+ timestamp_parser(input, eval_mode, tz, true).unwrap(),
+ Some(expected),
+ "{input:?} in {eval_mode:?}"
+ );
+ }
+ }
+
+ // `date_parser` ports `stringToDate`, whose `maxDigitsYear` is 7, so
a date cast keeps
+ // accepting the 7-digit year that a timestamp cast rejects.
+ for eval_mode in [EvalMode::Legacy, EvalMode::Try, EvalMode::Ansi] {
+ assert_eq!(
+ date_parser("0002020-01-01", eval_mode).unwrap(),
+ Some(18262)
+ );
+ }
+ }
+
+ #[test]
+ #[cfg_attr(miri, ignore)]
+ fn timestamp_ntz_parser_spark_segment_rules_test() {
+ for allow_time_zone in [true, false] {
+ for &(input, expected) in SPARK_SEGMENT_RULE_VALID {
+ for eval_mode in [EvalMode::Legacy, EvalMode::Try,
EvalMode::Ansi] {
+ assert_eq!(
+ timestamp_ntz_parser(input, eval_mode,
allow_time_zone, false).unwrap(),
+ Some(expected),
+ "{input:?} in {eval_mode:?},
allow_time_zone={allow_time_zone}"
+ );
+ }
+ }
+ for &input in SPARK_SEGMENT_RULE_INVALID {
+ for eval_mode in [EvalMode::Legacy, EvalMode::Try] {
+ assert_eq!(
+ timestamp_ntz_parser(input, eval_mode,
allow_time_zone, false).unwrap(),
+ None,
+ "{input:?} in {eval_mode:?},
allow_time_zone={allow_time_zone}"
+ );
+ }
+ assert!(
+ timestamp_ntz_parser(input, EvalMode::Ansi,
allow_time_zone, false).is_err(),
+ "{input:?} in Ansi, allow_time_zone={allow_time_zone}"
+ );
+ }
+ }
+ // A zone after an empty fraction is discarded when allowed and
rejected otherwise.
+ assert_eq!(
+ timestamp_ntz_parser("2020-01-01 12:34:56.Z", EvalMode::Legacy,
true, false).unwrap(),
+ Some(JAN1_2020_123456)
+ );
+ assert_eq!(
+ timestamp_ntz_parser("2020-01-01 12:34:56.Z", EvalMode::Legacy,
false, false).unwrap(),
+ None
+ );
+ assert!(
+ timestamp_ntz_parser("2020-01-01 12:34:56.Z", EvalMode::Ansi,
false, false).is_err()
+ );
+ }
+
+ #[test]
+ #[cfg_attr(miri, ignore)]
+ fn test_cast_string_to_timestamp_spark_segment_rules_array() {
+ // The reproducer from issue #5674, through the batch entry points.
+ let inputs = vec![
+ Some("2020-1-1"),
+ Some("2020-01-01 12:34:5"),
+ Some("2020-01-01 12:34:56."),
+ Some("2020-10-01Z"),
+ Some("0002020-01-01 00:00:00"),
+ ];
+ let expected = [
+ Some(JAN1_2020),
+ Some(JAN1_2020 + 45245 * 1_000_000),
+ Some(JAN1_2020_123456),
+ None,
+ None,
+ ];
+ let array: ArrayRef = Arc::new(StringArray::from(inputs));
+ let to_type = DataType::Timestamp(TimeUnit::Microsecond,
Some("UTC".into()));
+
+ let tz_result =
+ cast_string_to_timestamp(&array, &to_type, EvalMode::Legacy,
"UTC", true).unwrap();
+ let ntz_result =
+ cast_string_to_timestamp_ntz(&array, EvalMode::Legacy, true,
false).unwrap();
+ for result in [&tz_result, &ntz_result] {
+ let result = result
+ .as_any()
+ .downcast_ref::<PrimitiveArray<TimestampMicrosecondType>>()
+ .unwrap();
+ let actual: Vec<Option<i64>> = result.iter().collect();
+ assert_eq!(actual, expected);
+ }
+
+ // Under ANSI the first malformed row fails the batch and names the
raw input.
+ let tz_err =
+ cast_string_to_timestamp(&array, &to_type, EvalMode::Ansi, "UTC",
true).unwrap_err();
+ let ntz_err =
+ cast_string_to_timestamp_ntz(&array, EvalMode::Ansi, true,
false).unwrap_err();
+ for (err, expected_type) in [(tz_err, "TIMESTAMP"), (ntz_err,
"TIMESTAMP_NTZ")] {
+ match err {
+ SparkError::InvalidInputInCastToDatetime {
+ value,
+ from_type,
+ to_type,
+ } => {
+ assert_eq!(value, "2020-10-01Z");
+ assert_eq!(from_type, "STRING");
+ assert_eq!(to_type, expected_type);
+ }
+ other => panic!("Expected InvalidInputInCastToDatetime, got
{other:?}"),
+ }
+ }
+ }
+
/// Asserts every date parses to null in legacy and try mode. When
`expect_ansi_error` is set,
/// ANSI mode must raise CAST_INVALID_INPUT; otherwise ANSI mode must also
return null.
fn assert_dates(dates: &[&str], expect_ansi_error: bool) {
diff --git a/spark/src/test/scala/org/apache/comet/CometNativeCastSuite.scala
b/spark/src/test/scala/org/apache/comet/CometNativeCastSuite.scala
index 70eb654410..2a2854ece3 100644
--- a/spark/src/test/scala/org/apache/comet/CometNativeCastSuite.scala
+++ b/spark/src/test/scala/org/apache/comet/CometNativeCastSuite.scala
@@ -89,7 +89,7 @@ class CometNativeCastSuite extends CometTestBase with
AdaptiveSparkPlanHelper {
private val datePattern = "0123456789/" + whitespaceChars
- private val timestampPattern = "0123456789/:T" + whitespaceChars
+ private val timestampPattern = "0123456789/:T-.+Z" + whitespaceChars
lazy val usingParquetExecWithIncompatTypes: Boolean =
hasUnsignedSmallIntSafetyCheck(conf)
@@ -1438,10 +1438,12 @@ class CometNativeCastSuite extends CometTestBase with
AdaptiveSparkPlanHelper {
test("cast StringType to TimestampType") {
withSQLConf((SQLConf.SESSION_LOCAL_TIMEZONE.key, "UTC")) {
- val values = Seq("2020-01-01T12:34:56.123456", "T2") ++
gen.generateStrings(
- dataSize,
- timestampPattern,
- 8)
+ // 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 values = Seq("2020-01-01T12:34:56.123456", "T2") ++ fuzzValues
castTest(values.toDF("a"), DataTypes.TimestampType)
}
}
@@ -1541,6 +1543,17 @@ class CometNativeCastSuite extends CometTestBase with
AdaptiveSparkPlanHelper {
}
}
+ test("cast StringType to TimestampType - time-only offset leading
whitespace") {
+ withSQLConf(SQLConf.SESSION_LOCAL_TIMEZONE.key -> "UTC") {
+ // One Parquet-backed column value per query exercises each input in
Legacy, TRY and ANSI.
+ // Spark 4 rejects the leading whitespace; Spark 3.5 accepts it. NTZ
rejects time-only input.
+ Seq("\tT1:2:3 +08:00", " T1:2:3.4 +08:00", "T1:2:3 +08:00").foreach {
value =>
+ castTimestampTest(Seq(value).toDF("a"), DataTypes.TimestampType,
assertNative = true)
+ castTimestampTest(Seq(value).toDF("a"), DataTypes.TimestampNTZType,
assertNative = true)
+ }
+ }
+ }
+
Seq(DataTypes.TimestampType, DataTypes.TimestampNTZType).foreach { toType =>
test(s"cast StringType to $toType - out-of-range years") {
withSQLConf(SQLConf.SESSION_LOCAL_TIMEZONE.key -> "UTC") {
@@ -1579,6 +1592,145 @@ class CometNativeCastSuite extends CometTestBase with
AdaptiveSparkPlanHelper {
}
}
+ // Spark's SparkDateTimeUtils.parseTimestampString validates each segment
with isValidDigits:
+ // month/day/hour/minute/second take 1-2 digits, the fraction may be empty,
a timestamp year
+ // takes at most 6 digits (only a date takes 7), and a zone id is only
recognised after the
+ // seconds segment. Keep explicit cases as well as fuzz coverage for these
segment boundaries.
+ private val sparkSegmentRuleTimestamps = Seq(
+ // 1-2 digit segments
+ "2020-1",
+ "2020-1-1",
+ "2020-10-1",
+ "2020-12-1",
+ "-0001-01-01T12:34:56",
+ "2021-11-22 10:54:27 +08:00",
+ "2020-01-01 12:34:56 Z",
+ "2020-01-01 12:34:56\t+08:00",
+ "2020-01-01 12:34:56\n+08:00",
+ "2020-01-01 12:34:56.123 +08:00",
+ "2020-01-01 12:34:56. +08:00",
+ "2020-01-01 12:34:56 +08:00",
+ "2020-01-01 12:34:56 -08:00",
+ "2020-01-01 12:34:56 Europe/Moscow",
+ "-0001-01-01T12:34:56 +08:00",
+ "-0001-01-01T12:34:56-08:00",
+ "2020-1-1T1",
+ "2020-1-1 1:2",
+ "2020-01-01 12:34:5",
+ "2020-1-1T1:2:3.4",
+ // empty fraction, alone and before a zone
+ "2020-01-01 12:34:56.",
+ "2020-01-01 12:34:56.Z",
+ // 6-digit year is the timestamp maximum
+ "002020-01-01 00:00:00")
+
+ private val sparkSegmentRuleMalformedTimestamps = Seq(
+ "-0002020-01-01",
+ "2020-01-01 12:34:56.1٢٢٢",
+ "T1:2:3.1٢٢٢",
+ // Spark's scanner only accepts ASCII digits in timestamp segments
+ "٢020-1-1",
+ "2020-٢",
+ "2020-01-٢",
+ "2020-1-1T٢",
+ "2020-01-01T1:٢",
+ "2020-01-01T1:2:٣",
+ "2020-1-1T1:2:3.٢",
+ "2020-1-1T1:2:3.٢Z",
+ "T٢",
+ "T1:٢",
+ "T1:2:٣",
+ "T1:2:3.٢",
+ "1:٢",
+ "1:2:٣",
+ "1:2:3.٢",
+ // zone suffix before the seconds segment
+ "2020-01-01 12:34 +08:00",
+ "2020-01-01 12 +08:00",
+ "2020-01-01 +08:00",
+ "2020-01-01 Z",
+ "2020-01 +08:00",
+ "2020 +08:00",
+ "2020Z",
+ "2020-10-01Z",
+ "2020-01-01+05:30",
+ "2020-01-01-08:00",
+ "2020-10-01 UTC",
+ "2020-01-01T12Z",
+ "2020-01-01T12:34Z",
+ "2020-01-01 12:34 UTC",
+ "2020-01-01T12:34:Z",
+ // 7-digit year
+ "0002020-01-01",
+ "0002020-01-01 00:00:00",
+ // 3-digit segments
+ "2020-001-01",
+ "2020-01-01T12:345")
+
+ test("cast StringType to TimestampType - Spark segment rules") {
+ withSQLConf(SQLConf.SESSION_LOCAL_TIMEZONE.key -> "UTC") {
+ castTimestampTest(
+ sparkSegmentRuleTimestamps.toDF("a"),
+ DataTypes.TimestampType,
+ assertNative = true)
+ // One row per query so that every malformed value is checked under ANSI
mode rather
+ // than only the first row that fails a batch.
+ sparkSegmentRuleMalformedTimestamps.foreach { value =>
+ castTimestampTest(Seq(value).toDF("a"), DataTypes.TimestampType,
assertNative = true)
+ }
+ }
+ }
+
+ test("cast StringType to TimestampNTZType - Spark segment rules") {
+ withSQLConf(SQLConf.SESSION_LOCAL_TIMEZONE.key -> "UTC") {
+ castTimestampTest(
+ sparkSegmentRuleTimestamps.toDF("a"),
+ DataTypes.TimestampNTZType,
+ assertNative = true)
+ sparkSegmentRuleMalformedTimestamps.foreach { value =>
+ castTimestampTest(Seq(value).toDF("a"), DataTypes.TimestampNTZType,
assertNative = true)
+ }
+ }
+ }
+
+ test("cast StringType to TimestampType/TimestampNTZType - whitespace before
zone suffix") {
+ // Zone names use Java String.trim (<= U+0020), not Unicode whitespace
trimming.
+ val values = Seq(0x01, 0x0b, 0x0c, 0x1f, 0x7f, 0x00a0, 0x2009, 0x3000)
+ .map(ws => s"2020-01-01 12:34:56${ws.toChar}+08:00") ++ Seq(
+ "2020-01-01 12:34:56\u00a0UTC",
+ "2020-01-01 12:34:56\u00a0Z",
+ "2020-01-01 12:34:56.123\u00a0+08:00")
+ for (tz <- Seq("UTC", "America/Los_Angeles")) {
+ withSQLConf(SQLConf.SESSION_LOCAL_TIMEZONE.key -> tz) {
+ // One Parquet-backed value at a time checks every invalid input in
ANSI as well.
+ for (value <- values;
+ dataType <- Seq(DataTypes.TimestampType,
DataTypes.TimestampNTZType)) {
+ castTimestampTest(Seq(value).toDF("a"), dataType, assertNative =
true)
+ }
+ }
+ }
+ }
+
+ test("cast StringType to TimestampType/TimestampNTZType - numeric offset
validation") {
+ val malformed = Seq(
+ "2020-1\u0967",
+ "2020-\u09671",
+ "2020-01-1\u0967",
+ "2020-01-\u09671") ++ Seq("+08:000", "+008:00", "+18:01", "-18:01",
"+1:+1", "+1\u0967")
+ .flatMap(offset => Seq(s"2020-01-01 12:34:56 $offset", s"2020-01-01
12:34:56$offset"))
+ val valid = Seq("+08:00", "+8:0", "+0800", "+17:59", "+18:00", "-18:00")
+ .map(offset => s"2020-01-01 12:34:56 $offset")
+ for (tz <- Seq("UTC", "America/Los_Angeles")) {
+ withSQLConf(SQLConf.SESSION_LOCAL_TIMEZONE.key -> tz) {
+ // Each Parquet-backed value is checked separately in legacy, TRY and
ANSI modes.
+ for (value <- malformed ++ valid;
+ dataType <- Seq(DataTypes.TimestampType,
DataTypes.TimestampNTZType)) {
+ castTimestampTest(Seq(value).toDF("a"), dataType, assertNative =
true)
+ }
+ }
+ }
+ }
+
// CAST from BinaryType
test("cast BinaryType to StringType") {
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]