This is an automated email from the ASF dual-hosted git repository.
andygrove 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 a74839cf76 fix: match Spark whitespace trimming in to_time and
try_to_time (#5364)
a74839cf76 is described below
commit a74839cf76ff15a7de4128b4740e8133ae8d0d29
Author: Chao Sun <[email protected]>
AuthorDate: Sat Aug 15 12:24:17 2026 -0700
fix: match Spark whitespace trimming in to_time and try_to_time (#5364)
* fix: match Spark whitespace trimming in to_time and try_to_time
* test: address to_time trim review feedback
---
.../user-guide/latest/compatibility/index.md | 8 +-
native/spark-expr/src/conversion_funcs/mod.rs | 2 +-
native/spark-expr/src/conversion_funcs/trim.rs | 6 +-
native/spark-expr/src/datetime_funcs/to_time.rs | 245 ++++++++++++++++++---
.../sql-tests/expressions/datetime/to_time.sql | 77 +++++++
5 files changed, 306 insertions(+), 32 deletions(-)
diff --git a/docs/source/user-guide/latest/compatibility/index.md
b/docs/source/user-guide/latest/compatibility/index.md
index ae29a67bc1..001ec87606 100644
--- a/docs/source/user-guide/latest/compatibility/index.md
+++ b/docs/source/user-guide/latest/compatibility/index.md
@@ -134,10 +134,10 @@ so users hunting an unexpected value have a single place
to check:
`DECIMAL(1, 1)`) throws `NUMERIC_VALUE_OUT_OF_RANGE` regardless of the eval
mode. Spark returns
`NULL` under legacy and try mode, and only throws under ANSI
([#5068](https://github.com/apache/datafusion-comet/issues/5068)).
-- `CAST(string AS timestamp)`, `CAST(string AS timestamp_ntz)`, `to_time` and
`try_to_time` trim
- Unicode whitespace. Spark trims only the bytes `0x00`-`0x20` and `0x7F`, so
a value padded with
- an ASCII control byte 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
+- `CAST(string AS timestamp)` and `CAST(string AS timestamp_ntz)` trim Unicode
whitespace.
+ Spark trims only the bytes `0x00`-`0x20` and `0x7F`, so a value padded with
an ASCII control byte
+ 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)).
- 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
diff --git a/native/spark-expr/src/conversion_funcs/mod.rs
b/native/spark-expr/src/conversion_funcs/mod.rs
index 2f42c316b3..7b864e193b 100644
--- a/native/spark-expr/src/conversion_funcs/mod.rs
+++ b/native/spark-expr/src/conversion_funcs/mod.rs
@@ -20,5 +20,5 @@ pub mod cast;
mod numeric;
mod string;
mod temporal;
-mod trim;
+pub(crate) mod trim;
mod utils;
diff --git a/native/spark-expr/src/conversion_funcs/trim.rs
b/native/spark-expr/src/conversion_funcs/trim.rs
index 983bf34fa4..8cd7159096 100644
--- a/native/spark-expr/src/conversion_funcs/trim.rs
+++ b/native/spark-expr/src/conversion_funcs/trim.rs
@@ -31,9 +31,11 @@
//! `Double.parseDouble` trims before parsing, and
`Decimal.stringToJavaBigDecimal` does
//! `str.toString.trim`.
//!
+//! `to_time` and `try_to_time` also use [`trim_all`] after detecting an
optional AM/PM suffix;
+//! suffix detection itself first removes ASCII spaces only, matching Spark's
`stringToTime`.
+//!
//! \* `timestamp` and `timestamp_ntz` are listed for what Spark does; the
Comet parsers for those
-//! two targets have not been migrated to these helpers and still use
`str::trim`, as do `to_time`
-//! and `try_to_time` in `datetime_funcs::to_time`
+//! two targets have not been migrated to these helpers and still use
`str::trim`
//! (<https://github.com/apache/datafusion-comet/issues/5149>).
/// True for the bytes trimmed by
`org.apache.spark.unsafe.types.UTF8String.trimAll`, i.e. the
diff --git a/native/spark-expr/src/datetime_funcs/to_time.rs
b/native/spark-expr/src/datetime_funcs/to_time.rs
index 727998fdf8..249be04a4f 100644
--- a/native/spark-expr/src/datetime_funcs/to_time.rs
+++ b/native/spark-expr/src/datetime_funcs/to_time.rs
@@ -15,6 +15,7 @@
// specific language governing permissions and limitations
// under the License.
+use crate::conversion_funcs::trim::trim_all;
use arrow::array::{Array, StringArray, Time64NanosecondArray};
use datafusion::common::{DataFusionError, Result};
use datafusion::physical_plan::ColumnarValue;
@@ -80,26 +81,18 @@ pub fn spark_to_time(args: &[ColumnarValue], fail_on_error:
bool) -> Result<Colu
/// Parse a time string to nanoseconds from midnight, matching Spark's
stringToTime behavior.
/// Returns None for invalid input.
fn string_to_time(s: &str) -> Option<i64> {
- let trimmed = s.trim();
- if trimmed.is_empty() {
- return None;
- }
-
- // Spark's parseTimestampString gates the T-prefix branch on j == 0 (start
of
- // the trimmed string), so " T12:30" is rejected even though leading
whitespace
- // is trimmed: the original segment start differs from the trimmed
position.
- if trimmed.as_bytes()[0] == b'T' && s.as_bytes()[0].is_ascii_whitespace() {
- return None;
- }
-
- let bytes = trimmed.as_bytes();
- let num_chars = bytes.len();
-
- // Detect AM/PM suffix
- let (is_am, is_pm, has_suffix) = if num_chars > 2 {
- let last = bytes[num_chars - 1];
+ // Spark's stringToTime calls UTF8String.trimRight before looking for
AM/PM.
+ // Unlike trimAll, trimRight removes ASCII spaces only, so a control byte
+ // after AM/PM prevents the suffix from being recognized.
+ let right_trimmed = s.trim_end_matches(' ');
+ let bytes = right_trimmed.as_bytes();
+ let num_bytes = bytes.len();
+
+ // ASCII AM/PM suffix bytes cannot be UTF-8 continuation bytes, so byte
indexing is safe.
+ let (is_am, is_pm, has_suffix) = if num_bytes > 2 {
+ let last = bytes[num_bytes - 1];
if last == b'M' || last == b'm' {
- let second_last = bytes[num_chars - 2];
+ let second_last = bytes[num_bytes - 2];
let am = second_last == b'A' || second_last == b'a';
let pm = second_last == b'P' || second_last == b'p';
(am, pm, am || pm)
@@ -110,14 +103,24 @@ fn string_to_time(s: &str) -> Option<i64> {
(false, false, false)
};
- // Strip AM/PM suffix (and optional space before it)
- let time_str = if has_suffix {
- let end = num_chars - 2;
- let s = &trimmed[..end];
- s.trim_end()
+ // Spark passes the remaining segment to parseTimestampString, which trims
+ // all ASCII control bytes and spaces, including DELETE, from both ends.
+ // Unicode whitespace is intentionally preserved and fails to parse.
+ let untrimmed_time = if has_suffix {
+ &right_trimmed[..num_bytes - 2]
} else {
- trimmed
+ right_trimmed
};
+ let time_str = trim_all(untrimmed_time);
+ if time_str.is_empty() {
+ return None;
+ }
+
+ // parseTimestampString accepts a T-prefix only at the original segment
+ // start, before trimAll advances past any leading ASCII control bytes.
+ if time_str.starts_with('T') && !untrimmed_time.starts_with('T') {
+ return None;
+ }
// Parse the time components
let (hour, minute, second, micros) = parse_time_components(time_str)?;
@@ -479,4 +482,196 @@ mod tests {
assert_eq!(string_to_time(" T12:30:45"), None);
assert_eq!(string_to_time(" T12:30"), None);
}
+
+ #[test]
+ fn test_spark_trim_all_control_bytes() {
+ for (time, after_hour) in [("12:30:45", "30:45"), ("12:30", "30")] {
+ let expected = string_to_time(time);
+
+ for byte in (0_u8..=0x20).chain(std::iter::once(0x7f)) {
+ let padding = char::from(byte);
+ for input in [
+ format!("{padding}{time}"),
+ format!("{time}{padding}"),
+ format!("{padding}{time}{padding}"),
+ ] {
+ assert_eq!(
+ string_to_time(&input),
+ expected,
+ "padding byte 0x{byte:02X} in {input:?}"
+ );
+ }
+
+ let interior = format!("12:{padding}{after_hour}");
+ assert_eq!(
+ string_to_time(&interior),
+ None,
+ "interior padding byte 0x{byte:02X} in {interior:?}"
+ );
+ }
+ }
+ }
+
+ #[test]
+ fn test_unicode_whitespace_is_not_trimmed() {
+ for padding in [
+ '\u{0085}', '\u{00a0}', '\u{1680}', '\u{2000}', '\u{2003}',
'\u{2007}', '\u{2028}',
+ '\u{2029}', '\u{202f}', '\u{205f}', '\u{3000}',
+ ] {
+ assert!(padding.is_whitespace());
+
+ for input in [
+ format!("{padding}12:30:45"),
+ format!("12:30:45{padding}"),
+ format!("{padding}12:30:45{padding}"),
+ format!("12:{padding}30:45"),
+ format!("{padding}1:00:00 AM"),
+ format!("1:00:00{padding}AM"),
+ format!("1:00:00 AM{padding}"),
+ ] {
+ assert_eq!(
+ string_to_time(&input),
+ None,
+ "Unicode whitespace U+{:04X} in {input:?}",
+ padding as u32
+ );
+ }
+ }
+ }
+
+ #[test]
+ fn test_am_pm_control_byte_trimming() {
+ for (suffix, expected) in [
+ ("AM", NANOS_PER_HOUR),
+ ("PM", 13 * NANOS_PER_HOUR),
+ ("am", NANOS_PER_HOUR),
+ ("pm", 13 * NANOS_PER_HOUR),
+ ] {
+ for byte in (0_u8..=0x20).chain(std::iter::once(0x7f)) {
+ let padding = char::from(byte);
+
+ for input in [
+ format!("{padding}1:00:00 {suffix}"),
+ format!("1:00:00{padding}{suffix}"),
+ format!("{padding}1:00:00{padding}{suffix}"),
+ ] {
+ assert_eq!(
+ string_to_time(&input),
+ Some(expected),
+ "padding byte 0x{byte:02X} in {input:?}"
+ );
+ }
+
+ let trailing = format!("1:00:00 {suffix}{padding}");
+ let expected_trailing = (byte == b' ').then_some(expected);
+ assert_eq!(
+ string_to_time(&trailing),
+ expected_trailing,
+ "trailing padding byte 0x{byte:02X} in {trailing:?}"
+ );
+ }
+ }
+
+ assert_eq!(string_to_time("1:00:00 AM \t"), None);
+ assert_eq!(string_to_time("1:00:00 AM\t "), None);
+ assert_eq!(string_to_time("1:00:00 AM "), Some(NANOS_PER_HOUR));
+ }
+
+ #[test]
+ fn test_t_prefix_rejects_all_leading_trimmed_bytes() {
+ let expected = string_to_time("T12:30:45");
+
+ for byte in (0_u8..=0x20).chain(std::iter::once(0x7f)) {
+ let padding = char::from(byte);
+ let leading = format!("{padding}T12:30:45");
+ let trailing = format!("T12:30:45{padding}");
+
+ assert_eq!(
+ string_to_time(&leading),
+ None,
+ "leading padding byte 0x{byte:02X} in {leading:?}"
+ );
+ assert_eq!(
+ string_to_time(&trailing),
+ expected,
+ "trailing padding byte 0x{byte:02X} in {trailing:?}"
+ );
+ }
+ }
+
+ #[test]
+ fn test_t_prefix_am_pm_control_byte_trimming() {
+ for (time, seconds) in [("T1:30", 0), ("T1:30:45", 45)] {
+ for (suffix, hour) in [("AM", 1), ("PM", 13), ("am", 1), ("pm",
13)] {
+ let expected =
+ hour * NANOS_PER_HOUR + 30 * NANOS_PER_MINUTE + seconds *
NANOS_PER_SECOND;
+
+ assert_eq!(string_to_time(&format!("{time} {suffix}")),
Some(expected));
+
+ for byte in (0_u8..=0x20).chain(std::iter::once(0x7f)) {
+ let padding = char::from(byte);
+
+ for input in [
+ format!("{padding}{time} {suffix}"),
+ format!("{padding}{time}{padding}{suffix}"),
+ ] {
+ assert_eq!(
+ string_to_time(&input),
+ None,
+ "leading padding byte 0x{byte:02X} in {input:?}"
+ );
+ }
+
+ let before_suffix = format!("{time}{padding}{suffix}");
+ assert_eq!(
+ string_to_time(&before_suffix),
+ Some(expected),
+ "pre-suffix padding byte 0x{byte:02X} in
{before_suffix:?}"
+ );
+
+ let after_suffix = format!("{time} {suffix}{padding}");
+ assert_eq!(
+ string_to_time(&after_suffix),
+ (byte == b' ').then_some(expected),
+ "post-suffix padding byte 0x{byte:02X} in
{after_suffix:?}"
+ );
+ }
+ }
+ }
+ }
+
+ #[test]
+ fn test_spark_to_time_whitespace_error_modes() {
+ let input = StringArray::from(vec![
+ Some("\u{3000}12:30:45"),
+ Some("1:00:00 AM\t"),
+ Some("\u{1}12:30:45\u{7f}"),
+ Some("1:00:00\u{b}PM"),
+ None,
+ ]);
+ let args = [ColumnarValue::Array(Arc::new(input))];
+
+ assert!(matches!(
+ spark_to_time(&args, true),
+ Err(DataFusionError::Execution(message))
+ if message.contains("cannot be parsed to a TIME value")
+ ));
+
+ let ColumnarValue::Array(output) = spark_to_time(&args,
false).unwrap() else {
+ panic!("spark_to_time should return an array");
+ };
+ let output = output
+ .as_any()
+ .downcast_ref::<Time64NanosecondArray>()
+ .unwrap();
+
+ assert!(output.is_null(0));
+ assert!(output.is_null(1));
+ assert_eq!(
+ output.value(2),
+ 12 * NANOS_PER_HOUR + 30 * NANOS_PER_MINUTE + 45 * NANOS_PER_SECOND
+ );
+ assert_eq!(output.value(3), 13 * NANOS_PER_HOUR);
+ assert!(output.is_null(4));
+ }
}
diff --git
a/spark/src/test/resources/sql-tests/expressions/datetime/to_time.sql
b/spark/src/test/resources/sql-tests/expressions/datetime/to_time.sql
index b3ac439fd8..5522033f5c 100644
--- a/spark/src/test/resources/sql-tests/expressions/datetime/to_time.sql
+++ b/spark/src/test/resources/sql-tests/expressions/datetime/to_time.sql
@@ -242,6 +242,83 @@ SELECT try_to_time(' 12:30:45')
query
SELECT try_to_time(' 1:00:00 PM')
+-- Spark's time parser trims ASCII control characters, spaces, and DELETE
after detecting AM/PM,
+-- but never trims non-ASCII whitespace. Materialize the padding so every
query remains native.
+statement
+CREATE TABLE test_to_time_trim(name STRING, pad STRING) USING parquet
+
+statement
+INSERT INTO test_to_time_trim VALUES
+ ('a_none', ''),
+ ('b_nul_0x00', chr(0)),
+ ('c_soh_0x01', chr(1)),
+ ('d_tab_0x09', chr(9)),
+ ('e_vtab_0x0b', chr(11)),
+ ('f_us_0x1f', chr(31)),
+ ('g_space_0x20', ' '),
+ ('h_del_0x7f', chr(127)),
+ ('i_nbsp_u00a0', cast(X'C2A0' as string)),
+ ('j_ideographic_u3000', cast(X'E38080' as string))
+
+-- Leading, trailing, both-sided, and interior padding must match Spark for
every codepoint.
+query
+SELECT
+ name,
+ try_to_time(concat(pad, '12:30:45')),
+ try_to_time(concat('12:30:45', pad)),
+ try_to_time(concat(pad, '12:30:45', pad)),
+ try_to_time(concat('12:', pad, '30:45')),
+ try_to_time(concat(pad, '12:30')),
+ try_to_time(concat('12:30', pad)),
+ try_to_time(concat(pad, '12:30', pad)),
+ try_to_time(concat('12:', pad, '30'))
+FROM test_to_time_trim
+
+-- Leading trimAll padding invalidates a T prefix. Controls before AM/PM are
valid, but only ASCII
+-- spaces after AM/PM are trimmed before Spark checks the suffix, including
with a T prefix.
+query
+SELECT
+ name,
+ try_to_time(concat(pad, 'T12:30:45')),
+ try_to_time(concat('T12:30:45', pad)),
+ try_to_time(concat('1:00:00', pad, 'PM')),
+ try_to_time(concat('1:00:00 PM', pad)),
+ try_to_time(concat(pad, 'T12:30:45 PM')),
+ try_to_time(concat('T12:30:45', pad, 'PM')),
+ try_to_time(concat('T12:30:45 PM', pad)),
+ try_to_time(concat(pad, 'T12:30 PM')),
+ try_to_time(concat('T12:30', pad, 'PM')),
+ try_to_time(concat('T12:30 PM', pad))
+FROM test_to_time_trim
+
+-- The throwing variant must accept the same valid ASCII controls as
try_to_time.
+query
+SELECT
+ name,
+ to_time(concat(pad, '12:30:45', pad)),
+ to_time(concat('1:00:00', pad, 'PM'))
+FROM test_to_time_trim
+WHERE name IN (
+ 'a_none', 'b_nul_0x00', 'c_soh_0x01', 'd_tab_0x09', 'e_vtab_0x0b',
+ 'f_us_0x1f', 'g_space_0x20', 'h_del_0x7f')
+
+-- The throwing variant must reject Unicode whitespace instead of silently
parsing it.
+query expect_error(cannot be parsed to a TIME value)
+SELECT to_time(concat(pad, '12:30:45'))
+FROM test_to_time_trim
+WHERE name = 'i_nbsp_u00a0'
+
+query expect_error(cannot be parsed to a TIME value)
+SELECT to_time(concat('12:30:45', pad))
+FROM test_to_time_trim
+WHERE name = 'j_ideographic_u3000'
+
+-- Only literal ASCII spaces are removed before AM/PM suffix detection.
+query expect_error(cannot be parsed to a TIME value)
+SELECT to_time(concat('1:00:00 PM', pad))
+FROM test_to_time_trim
+WHERE name = 'd_tab_0x09'
+
-- to_time with format pattern falls back to Spark (not supported natively)
query expect_fallback(invoke is not supported)
SELECT to_time('12:30:45', 'HH:mm:ss')
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]